1use std::{
9 collections::HashMap,
10 sync::{
11 Arc, Mutex as StdMutex,
12 atomic::{AtomicI32, AtomicU8, AtomicU64, Ordering},
13 },
14 time::Duration,
15};
16
17use bytes::Bytes;
18use krabka_client_core::{
19 Client, ClientError, ClientFrameMax, ConnectionDispatchQueueCapacity, FetchMinBytes, security,
20 telemetry,
21};
22use krabka_protocol::{
23 owned::{
24 join_group_response::JoinGroupResponse, sync_group_request::SyncGroupRequestAssignment,
25 sync_group_response::SyncGroupResponse,
26 },
27 primitives::uuid::Uuid as WireUuid,
28 records,
29};
30use krabka_units::{
31 ByteSize, Time,
32 convert::{ByteSizeExt as _, StdDurationExt as _, TimeExt as _},
33 millis, minutes, secs,
34};
35use refined_type::{
36 Refined,
37 rule::{GreaterI32, GreaterI64, MinMaxI64, MinMaxU128},
38};
39use tokio::{
40 sync::{Mutex, Notify, mpsc, oneshot, watch},
41 task::JoinHandle,
42 time::{self, Instant, sleep, timeout},
43};
44use tokio_util::sync::CancellationToken;
45use tracing::field;
46
47#[cfg(test)]
48use crate::TopicPattern;
49#[cfg(test)]
50use crate::builder;
51#[cfg(test)]
52use crate::test_support;
53use crate::{
54 CloseOptions, GroupProtocol, assignor,
55 assignor::Assignor,
56 builder::{AutoOffsetReset, IsolationLevel, decode_assignment},
57 commit, consumer_group, control, coordinator,
58 coordinator::{
59 CoordinatorRefind, CoordinatorRetryPolicy, CoordinatorRetryRequest, CoordinatorState,
60 find_coordinator,
61 },
62 error::ConsumerError,
63 fetch_buffer,
64 group_metadata::ConsumerGroupMetadata,
65 offset_wire,
66 partition_state::PausedPartitions,
67 poll, position, rebalance_listener, subscription,
68};
69
70pub fn validated_positive_i32(value: i32, name: &str) -> Result<i32, String> {
71 GreaterI32::<0>::new(value)
72 .map(Refined::into_value)
73 .map_err(|error| format!("{name}: {error}"))
74}
75
76pub fn protocol_bytes(name: &str, value: ByteSize) -> Result<i32, String> {
77 let bytes = value.bytes_f64();
78 if bytes.is_finite() && bytes.fract() == 0.0 && (1.0..=f64::from(i32::MAX)).contains(&bytes) {
79 Ok(value.bytes_i32())
80 } else {
81 Err(format!(
82 "{name} must be a positive whole-byte value that fits i32"
83 ))
84 }
85}
86
87fn whole_milliseconds(value: Duration) -> u64 {
91 value
92 .as_secs()
93 .saturating_mul(1000)
94 .saturating_add(u64::from(value.subsec_millis()))
95}
96
97fn validated_millisecond_duration(name: &str, value: Duration) -> Result<u64, String> {
98 let milliseconds = MinMaxU128::<1, { u64::MAX as u128 }>::new(value.as_millis())
99 .map_err(|error| format!("{name}: {error}"))?
100 .into_value();
101 let milliseconds = u64::try_from(milliseconds).map_err(|error| format!("{name}: {error}"))?;
102 if Duration::from_millis(milliseconds) == value {
103 Ok(milliseconds)
104 } else {
105 Err(format!("{name} must be a whole number of milliseconds"))
106 }
107}
108
109pub async fn client_instance_id(
110 client: &Client,
111 timeout: Time,
112) -> Result<Option<uuid::Uuid>, ConsumerError> {
113 Ok(client.client_instance_id(timeout).await?)
114}
115
116#[allow(dead_code)] pub struct Consumer {
121 pub(crate) client: Client,
122 pub(crate) group_id: String,
123 pub(crate) coordinator_id: Arc<AtomicI32>,
128 pub(crate) retry_policy: CoordinatorRetryPolicy,
129 pub(crate) member_id: watch::Receiver<String>,
132 pub(crate) commit_identity: Arc<Mutex<CommitIdentity>>,
133 pub(crate) commit_serialization: Arc<Mutex<()>>,
134 pub(crate) commit_async_state: Arc<AtomicU8>,
135 pub(crate) commit_async_callbacks: commit::OffsetCommitCallbacks,
137 pub(crate) group_instance_id: Option<String>,
138 pub(crate) current_generation: Arc<AtomicI32>,
142 pub(crate) subscription: subscription::SharedSubscription,
144 pub(crate) group_protocol: crate::GroupProtocol,
146 pub(crate) assigned: Arc<Mutex<Vec<(String, i32)>>>,
148 pub(crate) assignment_changed: Arc<Notify>,
150 pub(crate) next_offsets: Arc<Mutex<HashMap<(String, i32), i64>>>,
152 pub(crate) end_offsets: Arc<Mutex<HashMap<(String, i32), i64>>>,
154 pub(crate) positions: Arc<Mutex<HashMap<(String, i32), position::PartitionPosition>>>,
156 pub(crate) topic_ids: Arc<Mutex<HashMap<String, WireUuid>>>,
159 pub(crate) session_timeout: Time,
160 pub(crate) heartbeat_interval: Time,
161 pub(crate) rebalance_protocol: assignor::RebalanceProtocol,
163 pub(crate) coordinator_shutdown: CancellationToken,
164 pub(crate) coordinator_handle: Option<JoinHandle<()>>,
165 pub(crate) isolation_level: IsolationLevel,
167 pub(crate) fetch_min: ByteSize,
168 pub(crate) fetch_max: ByteSize,
169 pub(crate) fetch_partition_max: ByteSize,
170 pub(crate) fetch_max_wait: Time,
172 pub(crate) fetches: poll::Fetches,
174 pub(crate) client_rack: Option<String>,
176 pub(crate) metadata_max_age: Time,
178 pub(crate) request_timeout: Time,
180 pub(crate) wakeup: control::WakeupHandle,
182 pub(crate) enforced_rebalances: mpsc::UnboundedSender<(String, u64)>,
184 pub(crate) default_api_timeout: Time,
187 pub(crate) paused: PausedPartitions,
189 pub(crate) auto_offset_reset: AutoOffsetReset,
193 pub(crate) poll_error: coordinator::PollErrorSlot,
196 pub(crate) auto_commit: Option<commit::AutoCommit>,
199 pub(crate) poll_signal: coordinator::PollSignal,
202 pub(crate) rebalance_pending: watch::Receiver<bool>,
205 pub(crate) max_poll_records: usize,
207 pub(crate) fetch_buffer: fetch_buffer::FetchBuffer,
209 pub(crate) close_operation: watch::Sender<control::CloseRequest>,
212 pub(crate) rebalance_listener: Option<rebalance_listener::SharedListener>,
214 pub(crate) listener_calls: mpsc::UnboundedReceiver<rebalance_listener::ListenerCall>,
216 pub(crate) assigned_callback_pending: rebalance_listener::AssignedCallbackPending,
219}
220
221#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
224pub enum GroupMembershipOperation {
225 LeaveGroup,
227 RemainInGroup,
230 #[default]
234 Default,
235}
236
237#[derive(Clone)]
238pub struct CommitIdentity {
239 pub generation: i32,
240 pub member_id: String,
241 pub ownership_ids: HashMap<(String, i32), u64>,
242 pub rejoin_on_poll: bool,
246}
247
248#[derive(Clone)]
249struct StartConfig {
250 bootstrap: String,
251 client_id: String,
252 group_id: String,
253 session_timeout: Time,
254 max_poll_interval: Time,
256 max_poll_records: usize,
258 heartbeat_interval: Time,
259 subscription_metadata_refresh_interval: Time,
260 subscribe: Vec<String>,
261 subscribe_pattern: Option<crate::TopicPattern>,
264 subscribe_regex: Option<String>,
266 exclude_internal_topics: bool,
268 group_protocol: crate::GroupProtocol,
270 server_assignor: Option<String>,
272 group_instance_id: Option<String>,
273 auto_offset_reset: AutoOffsetReset,
274 isolation_level: IsolationLevel,
275 assignors: Vec<Assignor>,
277 rebalance_protocol: assignor::RebalanceProtocol,
278 fetch_min: ByteSize,
279 fetch_max: ByteSize,
280 fetch_partition_max: ByteSize,
281 fetch_max_wait: Time,
282 metadata_max_age: Time,
283 default_api_timeout: Time,
284 request_timeout: Time,
285 dns_timeout: Time,
286 send_buffer: Option<ByteSize>,
287 receive_buffer: Option<ByteSize>,
288 dispatch_queue_capacity: krabka_client_core::ConnectionDispatchQueueCapacity,
289 frame_max: krabka_client_core::ClientFrameMax,
290 metadata_recovery_strategy: krabka_client_core::MetadataRecoveryStrategy,
291 metadata_recovery_rebootstrap_trigger: Time,
292 leave_group_timeout: Time,
293 client_rack: Option<String>,
294 security: Option<security::ClientSecurity>,
295 client_dns_lookup: krabka_client_core::ClientDnsLookup,
297 reconnect_backoff: Time,
299 reconnect_backoff_max: Time,
301 connections_max_idle: Time,
303 socket_connection_setup_timeout: Time,
305 socket_connection_setup_timeout_max: Time,
307 has_rebalance_listener: bool,
309 retry_policy: ConsumerRetryPolicy,
310 auto_commit_interval: Option<Duration>,
313 allow_auto_create_topics: bool,
315 telemetry: Option<telemetry::ClientTelemetryConfig>,
318}
319
320#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
321struct RetryTime(Duration);
322
323impl RetryTime {
324 fn new(name: &str, value: Time) -> Result<Self, String> {
325 let milliseconds = GreaterI64::<0>::new(value.millis_i64())
326 .map_err(|error| format!("{name}: {error}"))?
327 .into_value();
328 if !value.secs_f64().is_finite() || Time::from_millis(milliseconds) != value {
329 return Err(format!("{name} must be a whole number of milliseconds"));
330 }
331 Ok(Self(Duration::from_millis(
332 u64::try_from(milliseconds).map_err(|error| format!("{name}: {error}"))?,
333 )))
334 }
335
336 fn time(self) -> Time {
337 Time::from_std(self.0)
338 }
339}
340
341#[derive(Clone, Copy, Debug, Eq, PartialEq)]
343pub struct ConsumerRetryPolicy {
344 startup_attempt_timeout: RetryTime,
345 startup_deadline: RetryTime,
346 startup_initial_backoff: RetryTime,
347 startup_max_backoff: RetryTime,
348 coordinator_retry_timeout: RetryTime,
349 coordinator_initial_backoff: RetryTime,
350 coordinator_max_backoff: RetryTime,
351}
352
353pub const DEFAULT_CONSUMER_RETRY_BACKOFF_MAX: Time = millis(1000);
355
356#[bon::bon]
357impl ConsumerRetryPolicy {
358 #[builder(start_fn = builder, finish_fn = build)]
365 pub fn new(
366 #[builder(default = secs(90))] startup_attempt_timeout: Time,
367 #[builder(default = minutes(5))] startup_deadline: Time,
368 #[builder(default = millis(500))] startup_initial_backoff: Time,
369 #[builder(default = DEFAULT_CONSUMER_RETRY_BACKOFF_MAX)] startup_max_backoff: Time,
370 #[builder(default = secs(30))] coordinator_retry_timeout: Time,
371 #[builder(default = millis(100))] coordinator_initial_backoff: Time,
372 #[builder(default = DEFAULT_CONSUMER_RETRY_BACKOFF_MAX)] coordinator_max_backoff: Time,
373 ) -> Result<Self, String> {
374 let policy = Self {
375 startup_attempt_timeout: RetryTime::new(
376 "consumer startup attempt timeout",
377 startup_attempt_timeout,
378 )?,
379 startup_deadline: RetryTime::new("consumer startup deadline", startup_deadline)?,
380 startup_initial_backoff: RetryTime::new(
381 "consumer startup initial backoff",
382 startup_initial_backoff,
383 )?,
384 startup_max_backoff: RetryTime::new(
385 "consumer startup maximum backoff",
386 startup_max_backoff,
387 )?,
388 coordinator_retry_timeout: RetryTime::new(
389 "consumer coordinator retry timeout",
390 coordinator_retry_timeout,
391 )?,
392 coordinator_initial_backoff: RetryTime::new(
393 "consumer coordinator initial backoff",
394 coordinator_initial_backoff,
395 )?,
396 coordinator_max_backoff: RetryTime::new(
397 "consumer coordinator maximum backoff",
398 coordinator_max_backoff,
399 )?,
400 };
401 if policy.startup_attempt_timeout > policy.startup_deadline {
402 return Err(
403 "consumer startup attempt timeout must not exceed startup deadline".to_owned(),
404 );
405 }
406 if policy.startup_initial_backoff > policy.startup_max_backoff {
407 return Err(
408 "consumer startup initial backoff must not exceed startup maximum backoff"
409 .to_owned(),
410 );
411 }
412 if policy.coordinator_initial_backoff > policy.coordinator_max_backoff {
413 return Err(
414 "consumer coordinator initial backoff must not exceed coordinator maximum backoff"
415 .to_owned(),
416 );
417 }
418 Ok(policy)
419 }
420
421 #[must_use]
423 pub fn startup_attempt_timeout(self) -> Time {
424 self.startup_attempt_timeout.time()
425 }
426
427 #[must_use]
429 pub fn startup_deadline(self) -> Time {
430 self.startup_deadline.time()
431 }
432
433 #[must_use]
435 pub fn startup_initial_backoff(self) -> Time {
436 self.startup_initial_backoff.time()
437 }
438
439 #[must_use]
441 pub fn startup_max_backoff(self) -> Time {
442 self.startup_max_backoff.time()
443 }
444
445 #[must_use]
447 pub fn coordinator_retry_timeout(self) -> Time {
448 self.coordinator_retry_timeout.time()
449 }
450
451 #[must_use]
453 pub fn coordinator_initial_backoff(self) -> Time {
454 self.coordinator_initial_backoff.time()
455 }
456
457 #[must_use]
459 pub fn coordinator_max_backoff(self) -> Time {
460 self.coordinator_max_backoff.time()
461 }
462
463 pub(crate) fn with_retry_backoff_max(retry_backoff_max: Time) -> Result<Self, String> {
476 let startup_initial_backoff = if millis(500) < retry_backoff_max {
477 millis(500)
478 } else {
479 retry_backoff_max
480 };
481 let coordinator_initial_backoff = if millis(100) < retry_backoff_max {
482 millis(100)
483 } else {
484 retry_backoff_max
485 };
486 Self::builder()
487 .startup_initial_backoff(startup_initial_backoff)
488 .startup_max_backoff(retry_backoff_max)
489 .coordinator_initial_backoff(coordinator_initial_backoff)
490 .coordinator_max_backoff(retry_backoff_max)
491 .build()
492 }
493}
494
495impl Default for ConsumerRetryPolicy {
496 fn default() -> Self {
499 let retry_backoff_max = RetryTime(DEFAULT_CONSUMER_RETRY_BACKOFF_MAX.to_std());
500 Self {
501 startup_attempt_timeout: RetryTime(Duration::from_secs(90)),
502 startup_deadline: RetryTime(Duration::from_secs(300)),
503 startup_initial_backoff: RetryTime(Duration::from_millis(500)),
504 startup_max_backoff: retry_backoff_max,
505 coordinator_retry_timeout: RetryTime(Duration::from_secs(30)),
506 coordinator_initial_backoff: RetryTime(Duration::from_millis(100)),
507 coordinator_max_backoff: retry_backoff_max,
508 }
509 }
510}
511
512pub const DEFAULT_CONSUMER_LEAVE_GROUP_TIMEOUT: Time = secs(5);
514
515#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
517pub struct ConsumerLeaveGroupTimeout(Duration);
518
519impl ConsumerLeaveGroupTimeout {
520 pub fn new(value: Duration) -> Result<Self, String> {
527 validated_millisecond_duration("consumer leave-group timeout", value)?;
528 Ok(Self(value))
529 }
530
531 #[must_use]
533 pub const fn duration(self) -> Duration {
534 self.0
535 }
536
537 #[must_use]
539 pub fn milliseconds(self) -> u64 {
540 whole_milliseconds(self.0)
541 }
542}
543
544impl Default for ConsumerLeaveGroupTimeout {
545 fn default() -> Self {
546 Self(DEFAULT_CONSUMER_LEAVE_GROUP_TIMEOUT.to_std())
548 }
549}
550
551pub const DEFAULT_CONSUMER_SUBSCRIPTION_METADATA_REFRESH_INTERVAL: Time = secs(5);
553
554#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
556pub struct ConsumerFetchMaxBytes(i32);
557
558impl ConsumerFetchMaxBytes {
559 pub fn new(value: i32) -> Result<Self, String> {
565 validated_positive_i32(value, "consumer fetch max bytes").map(Self)
566 }
567
568 #[must_use]
570 pub const fn bytes(self) -> i32 {
571 self.0
572 }
573
574 #[must_use]
576 pub fn size(self) -> ByteSize {
577 ByteSize::from_bytes_i64(i64::from(self.0))
578 }
579}
580
581impl TryFrom<ByteSize> for ConsumerFetchMaxBytes {
582 type Error = String;
583
584 fn try_from(value: ByteSize) -> Result<Self, Self::Error> {
585 Self::new(protocol_bytes("consumer fetch max", value)?)
586 }
587}
588
589#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
591pub struct ConsumerFetchPartitionMaxBytes(i32);
592
593impl ConsumerFetchPartitionMaxBytes {
594 pub fn new(value: i32) -> Result<Self, String> {
600 validated_positive_i32(value, "consumer fetch partition max bytes").map(Self)
601 }
602
603 #[must_use]
605 pub const fn bytes(self) -> i32 {
606 self.0
607 }
608
609 #[must_use]
611 pub fn size(self) -> ByteSize {
612 ByteSize::from_bytes_i64(i64::from(self.0))
613 }
614}
615
616impl TryFrom<ByteSize> for ConsumerFetchPartitionMaxBytes {
617 type Error = String;
618
619 fn try_from(value: ByteSize) -> Result<Self, Self::Error> {
620 Self::new(protocol_bytes("consumer fetch partition max", value)?)
621 }
622}
623
624#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
626pub struct ConsumerSubscriptionMetadataRefreshInterval(Duration);
627
628impl ConsumerSubscriptionMetadataRefreshInterval {
629 pub fn new(value: Duration) -> Result<Self, String> {
636 validated_millisecond_duration("consumer subscription metadata refresh interval", value)?;
637 Ok(Self(value))
638 }
639
640 #[must_use]
642 pub const fn duration(self) -> Duration {
643 self.0
644 }
645
646 #[must_use]
648 pub fn milliseconds(self) -> u64 {
649 whole_milliseconds(self.0)
650 }
651}
652
653impl Default for ConsumerSubscriptionMetadataRefreshInterval {
654 fn default() -> Self {
655 Self(DEFAULT_CONSUMER_SUBSCRIPTION_METADATA_REFRESH_INTERVAL.to_std())
657 }
658}
659
660impl Drop for Consumer {
661 fn drop(&mut self) {
662 self.coordinator_shutdown.cancel();
663 }
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct Header {
670 pub key: String,
671 pub value: Option<Bytes>,
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
680pub enum TimestampType {
681 CreateTime,
683 LogAppendTime,
685}
686
687impl From<records::TimestampType> for TimestampType {
688 fn from(value: records::TimestampType) -> Self {
689 match value {
690 records::TimestampType::CreateTime => Self::CreateTime,
691 records::TimestampType::LogAppendTime => Self::LogAppendTime,
692 }
693 }
694}
695
696#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct ConsumerRecord {
703 pub topic: String,
704 pub partition: i32,
705 pub offset: i64,
706 pub leader_epoch: i32,
707 pub timestamp: i64,
710 pub timestamp_type: TimestampType,
712 pub key: Option<Bytes>,
713 pub value: Option<Bytes>,
714 pub headers: Vec<Header>,
715}
716
717fn initial_join_protocols(
720 assignors: &[Assignor],
721 subscribe: &[String],
722 client_rack: Option<&str>,
723) -> Result<Vec<(String, Bytes)>, krabka_protocol::ProtocolError> {
724 coordinator::join_protocols(assignors, subscribe, &[], -1, client_rack, None)
725}
726
727fn startup_join_fields(
730 group_id: &str,
731 group_instance_id: Option<&str>,
732 protocols: Vec<(String, bytes::Bytes)>,
733 (session_timeout_ms, rebalance_timeout_ms): (i32, i32),
734) -> coordinator::JoinRequestFields {
735 coordinator::JoinRequestFields {
736 group_id: group_id.to_owned(),
737 group_instance_id: group_instance_id.map(str::to_owned),
738 session_timeout_ms,
739 rebalance_timeout_ms,
740 protocols,
741 reason: String::new(),
742 }
743}
744
745fn first_join_member_id(resp: &JoinGroupResponse) -> Result<String, ConsumerError> {
746 let member_id = if resp.error_code == 79 || resp.error_code == 0 {
747 resp.member_id.clone()
748 } else {
749 return Err(ConsumerError::Server(resp.error_code));
750 };
751 if member_id.is_empty() {
752 return Err(ConsumerError::RebalanceFailed(
753 "broker did not assign a member_id".into(),
754 ));
755 }
756 Ok(member_id)
757}
758
759#[derive(bon::Builder, Default)]
760struct ConsumerClientOptions {
761 #[builder(default)]
762 metadata_scope: krabka_client_core::MetadataScope,
763 telemetry: Option<telemetry::ClientTelemetryConfig>,
764}
765
766async fn build_consumer_client(
767 config: &StartConfig,
768 options: ConsumerClientOptions,
769) -> Result<Client, ConsumerError> {
770 Ok(Client::builder()
771 .bootstrap(&config.bootstrap)
772 .client_id(config.client_id.clone())
773 .request_timeout(config.request_timeout)
774 .dns_timeout(config.dns_timeout)
775 .send_buffer(config.send_buffer)
776 .receive_buffer(config.receive_buffer)
777 .dispatch_queue_capacity(config.dispatch_queue_capacity.get())
778 .frame_max(config.frame_max.size())
779 .metadata_recovery_strategy(config.metadata_recovery_strategy)
780 .metadata_recovery_rebootstrap_trigger(config.metadata_recovery_rebootstrap_trigger)
781 .maybe_security(config.security.clone())
782 .client_dns_lookup(config.client_dns_lookup)
783 .reconnect_backoff(config.reconnect_backoff)
784 .reconnect_backoff_max(config.reconnect_backoff_max)
785 .connections_max_idle(config.connections_max_idle)
786 .socket_connection_setup_timeout(config.socket_connection_setup_timeout)
787 .socket_connection_setup_timeout_max(config.socket_connection_setup_timeout_max)
788 .metadata_scope(options.metadata_scope)
789 .maybe_telemetry(options.telemetry)
790 .build()
791 .await?)
792}
793
794async fn pattern_topics(config: &StartConfig) -> Result<Vec<String>, ConsumerError> {
798 let client = build_consumer_client(config, ConsumerClientOptions::default()).await?;
799 let metadata = client
800 .refresh_metadata_with(krabka_client_core::all_topics_request())
801 .await;
802 client.close();
803 let subscription = subscription::Subscription {
804 pattern: config.subscribe_pattern.clone(),
805 ..subscription::Subscription::topics(Vec::new(), config.exclude_internal_topics)
806 };
807 Ok(subscription.matching_topics(&metadata?).unwrap_or_default())
808}
809
810fn initial_commit_identity(
813 assigned: &[(String, i32)],
814 generation: i32,
815 member_id: &str,
816) -> (CommitIdentity, u64) {
817 let ownership_ids = assigned.iter().cloned().zip(1_u64..).collect();
818 let identity = CommitIdentity {
819 generation,
820 member_id: member_id.to_owned(),
821 ownership_ids,
822 rejoin_on_poll: false,
823 };
824 (identity, assigned.len() as u64 + 1)
825}
826
827fn queue_initial_assign_call(
831 calls: &rebalance_listener::ListenerCalls,
832 partitions: Vec<(String, i32)>,
833) {
834 let (done, _) = oneshot::channel();
835 let _ = calls.send(rebalance_listener::ListenerCall {
836 kind: rebalance_listener::ListenerCallKind::Assigned,
837 partitions,
838 done,
839 });
840}
841
842fn subscription_metadata_scope(
846 allow_auto_create_topics: bool,
847) -> krabka_client_core::MetadataScope {
848 krabka_client_core::MetadataScope::Topics {
849 allow_auto_topic_creation: allow_auto_create_topics,
850 }
851}
852
853fn is_subscribed_topic(subscribe: &[String], name: &str) -> bool {
854 subscribe.iter().any(|s| s == name)
855}
856
857fn is_group_leader(leader: &str, member_id: &str) -> bool {
858 leader == member_id
859}
860
861async fn leave_startup_member(
862 client: &Client,
863 coordinator_id: &AtomicI32,
864 group_id: &str,
865 member_id: &str,
866 group_instance_id: Option<String>,
867 leave_group_timeout: Time,
868) {
869 if !coordinator::should_send_leave_group(
870 member_id,
871 group_instance_id.as_deref(),
872 GroupMembershipOperation::Default,
873 ) {
874 return;
875 }
876 let broker = client.broker(coordinator_id.load(Ordering::Relaxed));
877 let send = broker.send(coordinator::build_leave_group_request(
878 group_id.to_owned(),
879 member_id.to_owned(),
880 None,
881 ));
882 let _ = timeout(leave_group_timeout.to_std(), send).await;
883}
884
885fn has_assigned_partitions(assigned_partitions: &[(String, i32)]) -> bool {
886 !assigned_partitions.is_empty()
887}
888
889pub fn starting_offset(committed: i64, auto_offset_reset: AutoOffsetReset) -> i64 {
890 if committed >= 0 {
891 committed
892 } else {
893 reset_starting_offset(auto_offset_reset)
894 }
895}
896
897pub fn reset_starting_offset(auto_offset_reset: AutoOffsetReset) -> i64 {
910 match auto_offset_reset {
911 AutoOffsetReset::Earliest => poll::BEGINNING_SENTINEL,
914 AutoOffsetReset::Latest | AutoOffsetReset::ByDuration(_) => poll::LATEST_SENTINEL,
916 AutoOffsetReset::None => poll::NO_OFFSET_SENTINEL,
918 }
919}
920
921pub const DEFAULT_CONSUMER_DEFAULT_API_TIMEOUT: Time = secs(60);
923
924pub const DEFAULT_CONSUMER_MAX_POLL_INTERVAL: Time = minutes(5);
926
927static CONSUMER_CLIENT_ID_SEQUENCE: AtomicU64 = AtomicU64::new(1);
929
930fn consumer_client_id(
935 client_id: Option<String>,
936 group_id: &str,
937 group_instance_id: Option<&str>,
938) -> String {
939 match client_id {
940 Some(client_id) if !client_id.is_empty() => client_id,
941 _ => group_instance_id.map_or_else(
942 || {
943 format!(
944 "consumer-{group_id}-{}",
945 CONSUMER_CLIENT_ID_SEQUENCE.fetch_add(1, Ordering::Relaxed)
946 )
947 },
948 |group_instance_id| format!("consumer-{group_id}-{group_instance_id}"),
949 ),
950 }
951}
952
953pub const DEFAULT_CONSUMER_METADATA_MAX_AGE: Time = minutes(5);
955
956pub const DEFAULT_CONSUMER_FETCH_MAX_WAIT: Time = millis(500);
958
959fn validated_fetch_max_wait(wait: Time) -> Result<Time, String> {
964 let milliseconds = MinMaxI64::<0, { i32::MAX as i64 }>::new(wait.millis_i64())
965 .map_err(|error| format!("consumer fetch max wait: {error}"))?
966 .into_value();
967 if !wait.secs_f64().is_finite() || Time::from_millis(milliseconds) != wait {
968 return Err("consumer fetch max wait must be a whole number of milliseconds".to_owned());
969 }
970 Ok(wait)
971}
972
973pub const DEFAULT_CONSUMER_MAX_POLL_RECORDS: usize = 500;
975
976fn validated_max_poll_records(records: usize) -> Result<usize, String> {
981 if records == 0 || i32::try_from(records).is_err() {
982 return Err("consumer max poll records must be from 1 to i32::MAX".to_owned());
983 }
984 Ok(records)
985}
986
987fn validated_max_poll_interval(interval: Time) -> Result<Time, String> {
993 let milliseconds = MinMaxI64::<1, { i32::MAX as i64 }>::new(interval.millis_i64())
994 .map_err(|error| format!("consumer max poll interval: {error}"))?
995 .into_value();
996 if !interval.secs_f64().is_finite() || Time::from_millis(milliseconds) != interval {
997 return Err("consumer max poll interval must be a whole number of milliseconds".to_owned());
998 }
999 Ok(interval)
1000}
1001
1002fn validated_auto_commit_interval(
1008 enable_auto_commit: bool,
1009 interval: Time,
1010) -> Result<Option<Duration>, String> {
1011 if !enable_auto_commit {
1012 return Ok(None);
1013 }
1014 let milliseconds = MinMaxI64::<0, { i32::MAX as i64 }>::new(interval.millis_i64())
1015 .map_err(|error| format!("consumer auto commit interval: {error}"))?
1016 .into_value();
1017 if !interval.secs_f64().is_finite() || Time::from_millis(milliseconds) != interval {
1018 return Err(
1019 "consumer auto commit interval must be a whole number of milliseconds".to_owned(),
1020 );
1021 }
1022 Ok(Some(interval.to_std()))
1023}
1024
1025fn primed_position(committed_epoch: i32) -> position::PartitionPosition {
1026 position::PartitionPosition {
1027 offset_epoch: krabka_ids::LeaderEpoch(committed_epoch),
1030 ..Default::default()
1031 }
1032}
1033
1034pub fn protocol_millis_i32(value: Time) -> i32 {
1041 i32::try_from(value.millis_i64_trunc()).unwrap_or(i32::MAX)
1042}
1043
1044#[bon::bon]
1045impl Consumer {
1046 #[builder(start_fn = builder, finish_fn = build)]
1074 #[tracing::instrument(
1075 name = "consumer.start",
1076 level = "info",
1077 skip_all,
1078 fields(group_id = ?group_id, client_id = ?client_id),
1079 err(level = "info")
1080 )]
1081 pub async fn start(
1084 #[builder(into)] bootstrap: String,
1085 #[builder(into)] client_id: Option<String>,
1086 #[builder(into)]
1089 group_id: Option<String>,
1090 #[builder(default = secs(45))] session_timeout: Time,
1091 #[builder(default = DEFAULT_CONSUMER_MAX_POLL_INTERVAL)] max_poll_interval: Time,
1092 #[builder(default = DEFAULT_CONSUMER_MAX_POLL_RECORDS)] max_poll_records: usize,
1093 #[builder(default = secs(3))] heartbeat_interval: Time,
1094 #[builder(default = DEFAULT_CONSUMER_SUBSCRIPTION_METADATA_REFRESH_INTERVAL)]
1095 subscription_metadata_refresh_interval: Time,
1096 #[builder(into, default)] subscribe: Vec<String>,
1097 subscribe_pattern: Option<crate::TopicPattern>,
1100 #[builder(default = true)]
1103 exclude_internal_topics: bool,
1104 #[builder(into)]
1109 subscribe_regex: Option<String>,
1110 #[builder(default)]
1114 group_protocol: crate::GroupProtocol,
1115 #[builder(into)]
1119 group_remote_assignor: Option<String>,
1120 #[builder(into)] group_instance_id: Option<String>,
1121 #[builder(default = AutoOffsetReset::Latest)] auto_offset_reset: AutoOffsetReset,
1122 #[builder(default = IsolationLevel::ReadUncommitted)] isolation_level: IsolationLevel,
1123 #[builder(into, default = Assignor::DEFAULT_LIST.to_vec())] assignors: Vec<Assignor>,
1124 #[builder(default = krabka_client_core::DEFAULT_FETCH_MIN.bytes_i32())]
1126 fetch_min: i32,
1127 #[builder(default = poll::DEFAULT_FETCH_MAX.bytes_i32())]
1129 fetch_max: i32,
1130 #[builder(default = poll::DEFAULT_FETCH_PARTITION_MAX.bytes_i32())]
1132 fetch_partition_max: i32,
1133 #[builder(default = DEFAULT_CONSUMER_FETCH_MAX_WAIT)] fetch_max_wait: Time,
1134 #[builder(default = DEFAULT_CONSUMER_METADATA_MAX_AGE)] metadata_max_age: Time,
1135 #[builder(default = DEFAULT_CONSUMER_DEFAULT_API_TIMEOUT)]
1140 default_api_timeout: Time,
1141 #[builder(default = secs(30))] request_timeout: Time,
1142 #[builder(default = krabka_client_core::DEFAULT_CLIENT_DNS_TIMEOUT)]
1144 dns_timeout: Time,
1145 #[builder(required, default = Some(krabka_client_core::DEFAULT_SEND_BUFFER))]
1147 send_buffer: Option<ByteSize>,
1148 #[builder(required, default = Some(krabka_client_core::DEFAULT_RECEIVE_BUFFER))]
1150 receive_buffer: Option<ByteSize>,
1151 #[builder(default = krabka_client_core::DEFAULT_CONNECTION_DISPATCH_QUEUE_CAPACITY)]
1152 dispatch_queue_capacity: usize,
1153 #[builder(default = krabka_client_core::DEFAULT_CLIENT_FRAME_MAX)] frame_max: ByteSize,
1154 #[builder(default)]
1155 metadata_recovery_strategy: krabka_client_core::MetadataRecoveryStrategy,
1156 #[builder(default = krabka_client_core::DEFAULT_METADATA_RECOVERY_REBOOTSTRAP_TRIGGER)]
1157 metadata_recovery_rebootstrap_trigger: Time,
1158 #[builder(default = DEFAULT_CONSUMER_LEAVE_GROUP_TIMEOUT)] leave_group_timeout: Time,
1159 #[builder(into)] client_rack: Option<String>,
1160 security: Option<security::ClientSecurity>,
1161 #[builder(default)]
1163 client_dns_lookup: krabka_client_core::ClientDnsLookup,
1164 #[builder(default = krabka_client_core::DEFAULT_RECONNECT_BACKOFF)]
1166 reconnect_backoff: Time,
1167 #[builder(default = krabka_client_core::DEFAULT_RECONNECT_BACKOFF_MAX)]
1169 reconnect_backoff_max: Time,
1170 #[builder(default = krabka_client_core::DEFAULT_CONNECTIONS_MAX_IDLE)]
1172 connections_max_idle: Time,
1173 #[builder(default = krabka_client_core::DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT)]
1175 socket_connection_setup_timeout: Time,
1176 #[builder(default = krabka_client_core::DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX)]
1178 socket_connection_setup_timeout_max: Time,
1179 rebalance_listener: Option<Box<dyn crate::ConsumerRebalanceListener>>,
1180 #[builder(default = DEFAULT_CONSUMER_RETRY_BACKOFF_MAX)]
1183 retry_backoff_max: Time,
1184 retry_policy: Option<ConsumerRetryPolicy>,
1187 enable_auto_commit: Option<bool>,
1190 #[builder(default = secs(5))] auto_commit_interval: Time,
1191 #[builder(default = true)]
1195 allow_auto_create_topics: bool,
1196 #[builder(default = true)]
1200 enable_metrics_push: bool,
1201 ) -> Result<Self, ConsumerError> {
1202 let group_id = group_id.unwrap_or_default();
1205 if group_id.is_empty() && !(subscribe.is_empty() && subscribe_pattern.is_none()) {
1206 return Err(ConsumerError::InvalidGroupId);
1207 }
1208 if group_id.is_empty() && enable_auto_commit == Some(true) {
1210 return Err(ConsumerError::InvalidConfig(
1211 "enable.auto.commit cannot be set to true when default group id (null) is used."
1212 .to_owned(),
1213 ));
1214 }
1215 let enable_auto_commit = enable_auto_commit.unwrap_or(!group_id.is_empty());
1216 if group_protocol == GroupProtocol::Classic && group_remote_assignor.is_some() {
1220 return Err(ConsumerError::InvalidConfig(
1221 "group.remote.assignor cannot be set when group.protocol=classic".to_owned(),
1222 ));
1223 }
1224 if !subscribe.is_empty() && subscribe_pattern.is_some() {
1225 return Err(ConsumerError::InvalidConfig(
1226 "subscribe and subscribe_pattern are mutually exclusive".to_owned(),
1227 ));
1228 }
1229 if subscribe_regex.is_some() && (!subscribe.is_empty() || subscribe_pattern.is_some()) {
1230 return Err(ConsumerError::InvalidConfig(
1231 "Subscription to topics, partitions and pattern are mutually exclusive".to_owned(),
1232 ));
1233 }
1234 if let Some(regex) = &subscribe_regex {
1235 if regex.is_empty() {
1236 return Err(ConsumerError::InvalidArgument(
1237 "Topic pattern to subscribe to cannot be empty".to_owned(),
1238 ));
1239 }
1240 if group_protocol != GroupProtocol::Consumer {
1241 return Err(ConsumerError::InvalidConfig(
1242 consumer_group::RE2J_NEEDS_CONSUMER_PROTOCOL.to_owned(),
1243 ));
1244 }
1245 if group_id.is_empty() {
1246 return Err(ConsumerError::InvalidGroupId);
1247 }
1248 }
1249 if subscribe.iter().any(String::is_empty) {
1250 return Err(ConsumerError::InvalidConfig(
1251 "Topic collection to subscribe to cannot contain null or empty topic".to_owned(),
1252 ));
1253 }
1254 if group_instance_id.as_deref().is_some_and(str::is_empty) {
1255 return Err(ConsumerError::InvalidConfig(
1256 "group_instance_id must not be empty".into(),
1257 ));
1258 }
1259 krabka_client_core::ClientDnsTimeout::new(dns_timeout)
1260 .map_err(ConsumerError::InvalidConfig)?;
1261 let fetch_min = FetchMinBytes::new(fetch_min)
1262 .map_err(ConsumerError::InvalidConfig)?
1263 .size();
1264 let fetch_max = ConsumerFetchMaxBytes::new(fetch_max)
1265 .map_err(ConsumerError::InvalidConfig)?
1266 .size();
1267 if fetch_min.bytes_i32() > fetch_max.bytes_i32() {
1268 return Err(ConsumerError::InvalidConfig(
1269 "consumer fetch min must not exceed consumer fetch max".to_owned(),
1270 ));
1271 }
1272 let fetch_partition_max = ConsumerFetchPartitionMaxBytes::new(fetch_partition_max)
1273 .map_err(ConsumerError::InvalidConfig)?
1274 .size();
1275 let leave_group_timeout = ConsumerLeaveGroupTimeout::new(leave_group_timeout.to_std())
1280 .map_err(ConsumerError::InvalidConfig)?;
1281 let subscription_metadata_refresh_interval =
1282 ConsumerSubscriptionMetadataRefreshInterval::new(
1283 subscription_metadata_refresh_interval.to_std(),
1284 )
1285 .map_err(ConsumerError::InvalidConfig)?;
1286 let dispatch_queue_capacity = ConnectionDispatchQueueCapacity::new(dispatch_queue_capacity)
1287 .map_err(ConsumerError::InvalidConfig)?;
1288 let frame_max =
1289 ClientFrameMax::try_from(frame_max).map_err(ConsumerError::InvalidConfig)?;
1290 let metadata_recovery_rebootstrap_trigger =
1291 krabka_client_core::MetadataRecoveryRebootstrapTrigger::new(
1292 metadata_recovery_rebootstrap_trigger,
1293 )
1294 .map_err(ConsumerError::InvalidConfig)?
1295 .time();
1296 let auto_commit_interval =
1297 validated_auto_commit_interval(enable_auto_commit, auto_commit_interval)
1298 .map_err(ConsumerError::InvalidConfig)?;
1299 let max_poll_interval =
1300 validated_max_poll_interval(max_poll_interval).map_err(ConsumerError::InvalidConfig)?;
1301 let max_poll_records =
1302 validated_max_poll_records(max_poll_records).map_err(ConsumerError::InvalidConfig)?;
1303 let fetch_max_wait =
1304 validated_fetch_max_wait(fetch_max_wait).map_err(ConsumerError::InvalidConfig)?;
1305 if metadata_max_age.secs_f64() < 0.0 || !metadata_max_age.secs_f64().is_finite() {
1306 return Err(ConsumerError::InvalidConfig(
1307 "consumer metadata max age must not be negative".to_owned(),
1308 ));
1309 }
1310 if default_api_timeout.secs_f64() < 0.0 || !default_api_timeout.secs_f64().is_finite() {
1311 return Err(ConsumerError::InvalidConfig(
1312 "consumer default api timeout must not be negative".to_owned(),
1313 ));
1314 }
1315 let retry_policy = match retry_policy {
1318 Some(retry_policy) => retry_policy,
1319 None => ConsumerRetryPolicy::with_retry_backoff_max(retry_backoff_max)
1320 .map_err(ConsumerError::InvalidConfig)?,
1321 };
1322 let rebalance_protocol =
1323 assignor::rebalance_protocol_of(&assignors).map_err(ConsumerError::InvalidConfig)?;
1324
1325 let client_id = consumer_client_id(client_id, &group_id, group_instance_id.as_deref());
1326 let telemetry = enable_metrics_push.then(|| {
1327 telemetry::ClientTelemetryConfig::consumer(
1328 Some(group_id.as_str()),
1329 group_instance_id.as_deref(),
1330 client_rack.as_deref(),
1331 )
1332 });
1333 let config = StartConfig {
1334 bootstrap,
1335 client_id,
1336 group_id,
1337 session_timeout,
1338 max_poll_interval,
1339 max_poll_records,
1340 heartbeat_interval,
1341 subscription_metadata_refresh_interval: Time::from_std(
1342 subscription_metadata_refresh_interval.duration(),
1343 ),
1344 subscribe,
1345 subscribe_pattern,
1346 subscribe_regex,
1347 exclude_internal_topics,
1348 group_protocol,
1349 server_assignor: group_remote_assignor,
1350 group_instance_id,
1351 auto_offset_reset,
1352 isolation_level,
1353 assignors,
1354 rebalance_protocol,
1355 fetch_min,
1356 fetch_max,
1357 fetch_partition_max,
1358 fetch_max_wait,
1359 metadata_max_age,
1360 default_api_timeout,
1361 request_timeout,
1362 dns_timeout,
1363 send_buffer,
1364 receive_buffer,
1365 dispatch_queue_capacity,
1366 frame_max,
1367 metadata_recovery_strategy,
1368 metadata_recovery_rebootstrap_trigger,
1369 leave_group_timeout: Time::from_std(leave_group_timeout.duration()),
1370 client_rack,
1371 security,
1372 client_dns_lookup,
1373 reconnect_backoff,
1374 reconnect_backoff_max,
1375 connections_max_idle,
1376 socket_connection_setup_timeout,
1377 socket_connection_setup_timeout_max,
1378 has_rebalance_listener: rebalance_listener.is_some(),
1379 retry_policy,
1380 auto_commit_interval,
1381 allow_auto_create_topics,
1382 telemetry,
1383 };
1384
1385 Self::retry_start_until_success(config, rebalance_listener).await
1386 }
1387
1388 async fn retry_start_until_success(
1392 config: StartConfig,
1393 rebalance_listener: Option<Box<dyn crate::ConsumerRebalanceListener>>,
1394 ) -> Result<Self, ConsumerError> {
1395 let started = Instant::now();
1396 let total = config.retry_policy.startup_deadline().to_std();
1397 let mut backoff = config.retry_policy.startup_initial_backoff().to_std();
1398 loop {
1399 let remaining = total.saturating_sub(started.elapsed());
1400 if remaining.is_zero() {
1401 return Err(ConsumerError::Client(ClientError::Timeout(
1402 config.retry_policy.startup_deadline(),
1403 )));
1404 }
1405 match timeout(
1406 config
1407 .retry_policy
1408 .startup_attempt_timeout()
1409 .to_std()
1410 .min(remaining),
1411 Box::pin(Self::start_once(config.clone())),
1412 )
1413 .await
1414 {
1415 Ok(Ok(mut consumer)) => {
1416 consumer.rebalance_listener =
1417 rebalance_listener.map(rebalance_listener::shared);
1418 return Ok(consumer);
1419 }
1420 Ok(Err(error)) => {
1421 if started.elapsed().as_time() < config.retry_policy.startup_deadline()
1422 && is_retriable_consumer_start_error(&error)
1423 {
1424 tracing::warn!(
1425 group = %config.group_id,
1426 %error,
1427 "consumer startup failed transiently; retrying with a fresh connection"
1428 );
1429 } else {
1430 return Err(error);
1431 }
1432 }
1433 Err(_elapsed) => {
1434 if started.elapsed().as_time() >= config.retry_policy.startup_deadline() {
1435 return Err(ConsumerError::Client(ClientError::Timeout(
1436 config.retry_policy.startup_deadline(),
1437 )));
1438 }
1439 tracing::warn!(
1440 group = %config.group_id,
1441 timeout = ?config.retry_policy.startup_attempt_timeout(),
1442 "consumer startup exceeded attempt timeout \
1443 (likely a cold-boot group-join stall); \
1444 retrying with a fresh connection"
1445 );
1446 }
1447 }
1448 sleep(backoff.min(total.saturating_sub(started.elapsed()))).await;
1449 backoff = backoff
1450 .saturating_mul(2)
1451 .min(config.retry_policy.startup_max_backoff().to_std());
1452 }
1453 }
1454
1455 #[tracing::instrument(
1465 name = "consumer.start_once",
1466 level = "info",
1467 skip_all,
1468 fields(
1469 group_id = %config.group_id,
1470 coordinator_id = field::Empty,
1471 member_id = field::Empty,
1472 generation = field::Empty,
1473 is_leader = field::Empty,
1474 assigned_partitions = field::Empty,
1475 ),
1476 err(level = "info")
1477 )]
1478 async fn start_once(mut config: StartConfig) -> Result<Self, ConsumerError> {
1479 if config.subscribe.is_empty()
1480 && config.subscribe_pattern.is_none()
1481 && config.subscribe_regex.is_none()
1482 {
1483 return start_without_subscription(config).await;
1484 }
1485 if config.subscribe_pattern.is_some() {
1486 config.subscribe = pattern_topics(&config).await?;
1487 }
1488 if config.group_protocol == GroupProtocol::Consumer {
1489 return start_without_subscription(config).await;
1492 }
1493 let finish_config = config.clone();
1494 let telemetry = config.telemetry.clone();
1495 let StartConfig {
1496 bootstrap,
1497 client_id,
1498 group_id,
1499 session_timeout,
1500 max_poll_interval,
1501 subscribe,
1502 group_instance_id,
1503 assignors,
1504 request_timeout,
1505 dns_timeout,
1506 send_buffer,
1507 receive_buffer,
1508 dispatch_queue_capacity,
1509 frame_max,
1510 metadata_recovery_strategy,
1511 metadata_recovery_rebootstrap_trigger,
1512 client_rack,
1513 security,
1514 client_dns_lookup,
1515 reconnect_backoff,
1516 reconnect_backoff_max,
1517 connections_max_idle,
1518 socket_connection_setup_timeout,
1519 socket_connection_setup_timeout_max,
1520 allow_auto_create_topics,
1521 ..
1522 } = config;
1523 let client = Client::builder()
1524 .bootstrap(&bootstrap)
1525 .client_id(client_id.clone())
1526 .request_timeout(request_timeout)
1527 .dns_timeout(dns_timeout)
1528 .send_buffer(send_buffer)
1529 .receive_buffer(receive_buffer)
1530 .dispatch_queue_capacity(dispatch_queue_capacity.get())
1531 .frame_max(frame_max.size())
1532 .metadata_recovery_strategy(metadata_recovery_strategy)
1533 .metadata_recovery_rebootstrap_trigger(metadata_recovery_rebootstrap_trigger)
1534 .maybe_security(security.clone())
1535 .client_dns_lookup(client_dns_lookup)
1536 .reconnect_backoff(reconnect_backoff)
1537 .reconnect_backoff_max(reconnect_backoff_max)
1538 .connections_max_idle(connections_max_idle)
1539 .socket_connection_setup_timeout(socket_connection_setup_timeout)
1540 .socket_connection_setup_timeout_max(socket_connection_setup_timeout_max)
1541 .metadata_scope(subscription_metadata_scope(allow_auto_create_topics))
1542 .maybe_telemetry(telemetry)
1543 .build()
1544 .await?;
1545 client.metadata_topics().set(subscribe.iter().cloned());
1546
1547 let session_timeout_ms = protocol_millis_i32(session_timeout);
1553 let rebalance_timeout_ms = protocol_millis_i32(max_poll_interval);
1556
1557 let coordinator_retry = CoordinatorRetryPolicy::from(config.retry_policy);
1565 let coordinator_id = Arc::new(AtomicI32::new(
1566 find_coordinator(&client, &group_id, coordinator_retry).await?,
1567 ));
1568 tracing::Span::current().record("coordinator_id", coordinator_id.load(Ordering::Relaxed));
1569
1570 let protocols = initial_join_protocols(&assignors, &subscribe, client_rack.as_deref())?;
1574
1575 let join_fields = startup_join_fields(
1580 &group_id,
1581 group_instance_id.as_deref(),
1582 protocols.clone(),
1583 (session_timeout_ms, rebalance_timeout_ms),
1584 );
1585 let refind = CoordinatorRefind {
1586 client: &client,
1587 group_id: &group_id,
1588 coordinator_id: &coordinator_id,
1589 };
1590 let r1 = CoordinatorRetryRequest::builder()
1591 .retry(coordinator_retry)
1592 .code(|r: &JoinGroupResponse| r.error_code)
1593 .make(|| refind.send_join_group(join_fields.request(String::new())))
1594 .refind(refind)
1595 .build()
1596 .run_with_refind()
1597 .await?;
1598 let member_id = first_join_member_id(&r1)?;
1599 tracing::Span::current().record("member_id", member_id.as_str());
1600
1601 let cleanup_client = client.clone();
1602 let cleanup_coordinator_id = Arc::clone(&coordinator_id);
1603 let cleanup_group_id = group_id.clone();
1604 let cleanup_member_id = member_id.clone();
1605 let cleanup_group_instance_id = group_instance_id.clone();
1606 let cleanup_leave_group_timeout = finish_config.leave_group_timeout;
1607 let start_result = finish_startup(
1608 finish_config,
1609 client,
1610 coordinator_id,
1611 member_id,
1612 protocols,
1613 (session_timeout_ms, rebalance_timeout_ms),
1614 )
1615 .await;
1616 match start_result {
1617 Ok(consumer) => Ok(consumer),
1618 Err(error) => {
1619 leave_startup_member(
1620 &cleanup_client,
1621 &cleanup_coordinator_id,
1622 &cleanup_group_id,
1623 &cleanup_member_id,
1624 cleanup_group_instance_id,
1625 cleanup_leave_group_timeout,
1626 )
1627 .await;
1628 Err(ConsumerError::StartupAfterJoin(Box::new(error)))
1629 }
1630 }
1631 }
1632}
1633
1634async fn finish_startup(
1635 config: StartConfig,
1636 client: Client,
1637 coordinator_id: Arc<AtomicI32>,
1638 member_id: String,
1639 protocols: Vec<(String, bytes::Bytes)>,
1640 timeouts_ms: (i32, i32),
1641) -> Result<Consumer, ConsumerError> {
1642 let spawn_config = config.clone();
1643 let coordinator_retry = CoordinatorRetryPolicy::from(config.retry_policy);
1644 let StartConfig {
1645 group_id,
1646 group_instance_id,
1647 auto_offset_reset,
1648 ..
1649 } = config;
1650 let mut join_fields = startup_join_fields(
1653 &group_id,
1654 group_instance_id.as_deref(),
1655 protocols,
1656 timeouts_ms,
1657 );
1658 join_fields.reason = format!("need to re-join with the given member-id: {member_id}");
1659 let r2 = coordinator::JoinGroupRetry::builder()
1661 .client(&client)
1662 .group_id(&group_id)
1663 .coordinator_id(&coordinator_id)
1664 .retry(coordinator_retry)
1665 .fields(&join_fields)
1666 .member_id(member_id.clone())
1667 .build()
1668 .run()
1669 .await?;
1670 if r2.error_code != 0 {
1671 return Err(ConsumerError::Server(r2.error_code));
1672 }
1673
1674 let InitialAssignment {
1675 assigned_partitions,
1676 group_topics,
1677 topic_ids,
1678 topic_partitions,
1679 } = resolve_initial_assignment(&spawn_config, &client, &coordinator_id, &member_id, &r2)
1680 .await?;
1681
1682 let mut next_offsets: HashMap<(String, i32), i64> = HashMap::new();
1684 let mut positions: HashMap<(String, i32), position::PartitionPosition> = HashMap::new();
1685 if has_assigned_partitions(&assigned_partitions) {
1686 let mut by_topic: HashMap<String, Vec<i32>> = HashMap::new();
1687 for (t, p) in &assigned_partitions {
1688 by_topic.entry(t.clone()).or_default().push(*p);
1689 }
1690 let of = coordinator::send_offset_fetch(
1693 &client,
1694 &group_id,
1695 &coordinator_id,
1696 &offset_wire::build_offset_fetch(&group_id, &by_topic),
1697 coordinator_retry,
1698 )
1699 .await?;
1700 for (name, partition_index, committed, committed_epoch) in
1701 offset_wire::parse_offset_fetch(&of)
1702 {
1703 let starting = starting_offset(committed, auto_offset_reset);
1704 next_offsets.insert((name.clone(), partition_index), starting);
1705 positions.insert((name, partition_index), primed_position(committed_epoch));
1706 }
1707 }
1708
1709 spawn_consumer(
1710 spawn_config,
1711 client,
1712 coordinator_id,
1713 member_id,
1714 StartupState::builder()
1715 .generation_id(r2.generation_id)
1716 .group_topics(group_topics)
1717 .assigned_partitions(assigned_partitions)
1718 .next_offsets(next_offsets)
1719 .positions(positions)
1720 .topic_ids(topic_ids)
1721 .topic_partitions(topic_partitions)
1722 .build(),
1723 )
1724 .await
1725}
1726
1727struct InitialAssignment {
1728 assigned_partitions: Vec<(String, i32)>,
1729 group_topics: Vec<String>,
1732 topic_ids: HashMap<String, WireUuid>,
1733 topic_partitions: HashMap<String, i32>,
1734}
1735
1736async fn resolve_initial_assignment(
1737 config: &StartConfig,
1738 client: &Client,
1739 coordinator_id: &Arc<AtomicI32>,
1740 member_id: &str,
1741 r2: &JoinGroupResponse,
1742) -> Result<InitialAssignment, ConsumerError> {
1743 let group_id = &config.group_id;
1744 let subscribe = &config.subscribe;
1745 let group_instance_id = &config.group_instance_id;
1746 let protocol_name = r2.protocol_name.as_deref().unwrap_or_default();
1747 let is_leader = is_group_leader(&r2.leader, member_id);
1755 let group_topics = if is_leader {
1758 coordinator::group_subscription_topics(r2, subscribe)
1759 } else {
1760 subscribe.clone()
1761 };
1762 client.metadata_topics().set(group_topics.iter().cloned());
1763 let md = client.refresh_metadata().await;
1764 client.metadata_topics().set(subscribe.iter().cloned());
1765 let md = md?;
1766 let mut topic_ids: HashMap<String, WireUuid> = HashMap::new();
1767 let mut topic_partitions: HashMap<String, i32> = HashMap::new();
1768 for t in &md.topics {
1769 let Some(name) = &t.name else { continue };
1770 if is_subscribed_topic(&group_topics, name) {
1771 let count = i32::try_from(t.partitions.len()).unwrap_or(i32::MAX);
1772 topic_partitions.insert(name.clone(), count);
1773 topic_ids.insert(name.clone(), t.topic_id);
1774 }
1775 }
1776
1777 tracing::Span::current().record("generation", r2.generation_id);
1778 tracing::Span::current().record("is_leader", is_leader);
1779 let assignments_for_sync: Vec<SyncGroupRequestAssignment> = if is_leader {
1780 coordinator::leader_assignments(&config.assignors, r2, &md)?
1781 } else {
1782 Vec::new()
1783 };
1784
1785 let r3 = CoordinatorRetryRequest::builder()
1789 .retry(config.retry_policy.into())
1790 .code(|r: &SyncGroupResponse| r.error_code)
1791 .make(|| {
1792 let group_id = group_id.clone();
1793 let protocol_name = protocol_name.to_owned();
1794 let member_id = member_id.to_owned();
1795 let assignments_for_sync = assignments_for_sync.clone();
1796 let generation_id = r2.generation_id;
1797 let group_instance_id = group_instance_id.clone();
1798 let target = coordinator_id.load(Ordering::Relaxed);
1799 async move {
1800 client
1801 .broker(target)
1802 .send(
1803 coordinator::SyncGroupRequestConfig::builder()
1804 .group_id(group_id)
1805 .generation_id(generation_id)
1806 .member_id(member_id)
1807 .maybe_group_instance_id(group_instance_id)
1808 .protocol_name(protocol_name)
1809 .assignments(assignments_for_sync)
1810 .build(),
1811 )
1812 .await
1813 .map_err(ConsumerError::from)
1814 }
1815 })
1816 .refind(CoordinatorRefind {
1817 client,
1818 group_id,
1819 coordinator_id,
1820 })
1821 .build()
1822 .run_with_refind()
1823 .await?;
1824 if r3.error_code != 0 {
1825 return Err(ConsumerError::Server(r3.error_code));
1826 }
1827 let assigned_partitions = decode_assignment(&r3.assignment);
1828 tracing::Span::current().record("assigned_partitions", assigned_partitions.len());
1829
1830 Ok(InitialAssignment {
1831 assigned_partitions,
1832 group_topics,
1833 topic_ids,
1834 topic_partitions,
1835 })
1836}
1837
1838#[derive(bon::Builder, Default)]
1839struct StartupState {
1840 #[builder(default)]
1841 generation_id: i32,
1842 #[builder(default)]
1844 group_topics: Vec<String>,
1845 #[builder(default)]
1846 assigned_partitions: Vec<(String, i32)>,
1847 #[builder(default)]
1848 next_offsets: HashMap<(String, i32), i64>,
1849 #[builder(default)]
1850 positions: HashMap<(String, i32), position::PartitionPosition>,
1851 #[builder(default)]
1852 topic_ids: HashMap<String, WireUuid>,
1853 #[builder(default)]
1854 topic_partitions: HashMap<String, i32>,
1855}
1856
1857#[cfg(test)]
1858#[derive(bon::Builder)]
1859#[builder(start_fn = builder, finish_fn = build)]
1860struct TestConsumerStart {
1861 bootstrap: String,
1862 config: StartConfig,
1863 startup: StartupState,
1864}
1865
1866#[cfg(test)]
1867async fn start_test_consumer(start: TestConsumerStart) -> Consumer {
1868 let client = Client::builder()
1869 .bootstrap(start.bootstrap)
1870 .build()
1871 .await
1872 .expect("client");
1873 spawn_consumer(
1874 start.config,
1875 client,
1876 Arc::new(AtomicI32::new(0)),
1877 "member-a".into(),
1878 start.startup,
1879 )
1880 .await
1881 .expect("spawn consumer")
1882}
1883
1884async fn start_without_subscription(config: StartConfig) -> Result<Consumer, ConsumerError> {
1889 let client = build_consumer_client(
1890 &config,
1891 ConsumerClientOptions::builder()
1892 .metadata_scope(subscription_metadata_scope(config.allow_auto_create_topics))
1893 .maybe_telemetry(config.telemetry.clone())
1894 .build(),
1895 )
1896 .await?;
1897 let coordinator_id = if config.group_id.is_empty() {
1898 -1
1899 } else {
1900 find_coordinator(&client, &config.group_id, config.retry_policy.into()).await?
1901 };
1902 let member_id = if config.group_protocol == GroupProtocol::Consumer {
1905 uuid::Uuid::new_v4().to_string()
1906 } else {
1907 String::new()
1908 };
1909 client
1910 .metadata_topics()
1911 .set(config.subscribe.iter().cloned());
1912 spawn_consumer(
1913 config,
1914 client,
1915 Arc::new(AtomicI32::new(coordinator_id)),
1916 member_id,
1917 StartupState::builder().generation_id(-1).build(),
1918 )
1919 .await
1920}
1921
1922async fn coordinator_task_client(
1930 config: &StartConfig,
1931 group_topics: &[String],
1932) -> Result<Option<Client>, ConsumerError> {
1933 if config.subscribe.is_empty()
1934 && config.subscribe_pattern.is_none()
1935 && config.subscribe_regex.is_none()
1936 {
1937 return Ok(None);
1938 }
1939 let client = build_consumer_client(
1940 config,
1941 ConsumerClientOptions::builder()
1942 .metadata_scope(subscription_metadata_scope(config.allow_auto_create_topics))
1943 .build(),
1944 )
1945 .await?;
1946 let topics = if group_topics.is_empty() {
1949 &config.subscribe
1950 } else {
1951 group_topics
1952 };
1953 client.metadata_topics().set(topics.iter().cloned());
1954 Ok(Some(client))
1955}
1956
1957async fn spawn_consumer(
1958 config: StartConfig,
1959 client: Client,
1960 coordinator_id: Arc<AtomicI32>,
1961 member_id: String,
1962 startup: StartupState,
1963) -> Result<Consumer, ConsumerError> {
1964 let coordinator_client = coordinator_task_client(&config, &startup.group_topics).await?;
1965 let StartConfig {
1966 group_id,
1967 session_timeout,
1968 max_poll_interval,
1969 max_poll_records,
1970 heartbeat_interval,
1971 subscription_metadata_refresh_interval,
1972 subscribe,
1973 subscribe_pattern,
1974 subscribe_regex,
1975 exclude_internal_topics,
1976 group_protocol,
1977 server_assignor,
1978 group_instance_id,
1979 auto_offset_reset,
1980 isolation_level,
1981 assignors,
1982 rebalance_protocol,
1983 fetch_min,
1984 fetch_max,
1985 fetch_partition_max,
1986 fetch_max_wait,
1987 metadata_max_age,
1988 default_api_timeout,
1989 request_timeout,
1990 leave_group_timeout,
1991 client_rack,
1992 has_rebalance_listener,
1993 retry_policy,
1994 auto_commit_interval,
1995 ..
1996 } = config;
1997 let subscription = subscription::shared(
1998 subscribe.clone(),
1999 subscribe_pattern,
2000 exclude_internal_topics,
2001 );
2002 if subscribe_regex.is_some() {
2003 subscription.send_modify(|state| state.regex.clone_from(&subscribe_regex));
2004 }
2005 let StartupState {
2006 generation_id,
2007 assigned_partitions,
2008 next_offsets,
2009 positions,
2010 topic_ids,
2011 topic_partitions,
2012 ..
2013 } = startup;
2014 let standalone = coordinator_client.is_none();
2016 let coordinator_client = coordinator_client.unwrap_or_else(|| client.clone());
2017
2018 let (identity, next_ownership_id) =
2019 initial_commit_identity(&assigned_partitions, generation_id, &member_id);
2020 let initial_assignment = assigned_partitions.clone();
2021 let assigned = Arc::new(Mutex::new(assigned_partitions));
2022 let assignment_changed = Arc::new(Notify::new());
2023 let commit_identity = Arc::new(Mutex::new(identity));
2024 let commit_serialization = Arc::new(Mutex::new(()));
2025 let commit_async_state = Arc::new(AtomicU8::new(0));
2026 let next_offsets = Arc::new(Mutex::new(next_offsets));
2027 let end_offsets = Arc::new(Mutex::new(HashMap::new()));
2028 let positions = Arc::new(Mutex::new(positions));
2029 let topic_ids = Arc::new(Mutex::new(topic_ids));
2030 let current_generation = Arc::new(AtomicI32::new(generation_id));
2033
2034 let poll_error = coordinator::PollErrorSlot::default();
2035 let poll_signal = coordinator::PollSignal::default();
2036 let rebalance_pending = watch::Sender::new(group_protocol == GroupProtocol::Consumer);
2039 let rebalance_pending_receiver = rebalance_pending.subscribe();
2040 let close_operation = watch::Sender::new(control::CloseRequest::default());
2041 let (listener_sender, listener_calls) = mpsc::unbounded_channel();
2042 let (enforced_rebalances, enforced_rebalance_reasons) = mpsc::unbounded_channel();
2043 let assigned_callback_pending = rebalance_listener::AssignedCallbackPending::default();
2044 if has_rebalance_listener && !standalone {
2046 queue_initial_assign_call(&listener_sender, initial_assignment);
2047 }
2048 let auto_commit = auto_commit_interval.map(commit::AutoCommit::new);
2049
2050 let shutdown = CancellationToken::new();
2051 let published_member_id = watch::Sender::new(member_id.clone());
2052 let live_member_id = published_member_id.subscribe();
2053 let state = CoordinatorState {
2054 client: coordinator_client,
2055 group_id: group_id.clone(),
2056 coordinator_id: Arc::clone(&coordinator_id),
2057 member_id,
2058 published_member_id,
2059 commit_identity: Arc::clone(&commit_identity),
2060 group_instance_id: group_instance_id.clone(),
2061 generation_id,
2062 current_generation: Arc::clone(¤t_generation),
2063 assignors,
2064 rebalance_protocol,
2065 subscription: Arc::clone(&subscription),
2066 subscription_changes: subscription.subscribe(),
2067 joined_topics: subscribe,
2068 seen_unsubscribes: 0,
2069 group_protocol,
2070 server_assignor,
2071 member_epoch: 0,
2072 sent_heartbeat_fields: consumer_group::SentFields::default(),
2073 assigned: Arc::clone(&assigned),
2074 assignment_changed: Arc::clone(&assignment_changed),
2075 next_ownership_id,
2076 next_offsets: Arc::clone(&next_offsets),
2077 end_offsets: Arc::clone(&end_offsets),
2078 positions: Arc::clone(&positions),
2079 topic_ids: Arc::clone(&topic_ids),
2080 session_timeout,
2081 max_poll_interval,
2082 heartbeat_interval,
2083 subscription_metadata_refresh_interval,
2084 leave_group_timeout,
2085 auto_offset_reset,
2086 client_rack: client_rack.clone(),
2087 initial_subscribed_counts: topic_partitions,
2093 retry_policy: retry_policy.into(),
2094 poll_error: Arc::clone(&poll_error),
2095 auto_commit: auto_commit.clone(),
2096 commit_serialization: Arc::clone(&commit_serialization),
2097 join_prepared: false,
2098 polls: poll_signal.subscribe(),
2099 rebalance_pending,
2100 close_operation: close_operation.subscribe(),
2101 rejoin_reason: String::new(),
2102 listener_calls: has_rebalance_listener.then_some(listener_sender),
2103 enforced_rebalances: enforced_rebalance_reasons,
2104 lost_partitions: Vec::new(),
2105 assigned_callback_pending: Arc::clone(&assigned_callback_pending),
2106 poll_timer: coordinator::PollTimer::new(max_poll_interval),
2107 poll_timeout_in_callback: false,
2108 };
2109 let coord_handle = (!standalone).then(|| match group_protocol {
2113 GroupProtocol::Classic => tokio::spawn(coordinator::run(state, shutdown.clone())),
2114 GroupProtocol::Consumer => tokio::spawn(consumer_group::run(state, shutdown.clone())),
2115 });
2116
2117 Ok(Consumer {
2118 client,
2119 group_id,
2120 coordinator_id,
2121 retry_policy: retry_policy.into(),
2122 member_id: live_member_id,
2123 commit_identity,
2124 commit_serialization,
2125 commit_async_state,
2126 commit_async_callbacks: Arc::default(),
2127 group_instance_id,
2128 current_generation,
2129 subscription,
2130 group_protocol,
2131 assigned,
2132 assignment_changed,
2133 next_offsets,
2134 end_offsets,
2135 positions,
2136 topic_ids,
2137 session_timeout,
2138 heartbeat_interval,
2139 rebalance_protocol,
2140 coordinator_shutdown: shutdown,
2141 coordinator_handle: coord_handle,
2142 isolation_level,
2143 fetch_min,
2144 fetch_max,
2145 fetch_partition_max,
2146 fetch_max_wait,
2147 fetches: poll::Fetches::default(),
2148 client_rack,
2149 metadata_max_age,
2150 request_timeout,
2151 wakeup: control::WakeupHandle::default(),
2152 enforced_rebalances,
2153 default_api_timeout,
2154 paused: StdMutex::default(),
2155 auto_offset_reset,
2156 poll_error,
2157 auto_commit,
2158 poll_signal,
2159 rebalance_pending: rebalance_pending_receiver,
2160 max_poll_records,
2161 fetch_buffer: fetch_buffer::FetchBuffer::default(),
2162 close_operation,
2163 rebalance_listener: None,
2164 listener_calls,
2165 assigned_callback_pending,
2166 })
2167}
2168
2169fn is_retriable_consumer_start_error(error: &ConsumerError) -> bool {
2175 matches!(
2185 error,
2186 ConsumerError::Server(14 | 15 | 16 | 22 | 25 | 27 | 79)
2190 | ConsumerError::Client(ClientError::Disconnected)
2191 ) || matches!(
2192 error,
2193 ConsumerError::StartupAfterJoin(inner)
2194 if is_retriable_consumer_start_error(inner)
2195 || matches!(
2196 inner.as_ref(),
2197 ConsumerError::Client(ClientError::Timeout(_))
2198 )
2199 )
2200}
2201
2202impl Consumer {
2203 #[must_use]
2205 pub fn group_id(&self) -> &str {
2206 &self.group_id
2207 }
2208
2209 #[must_use]
2212 pub fn member_id(&self) -> String {
2213 self.member_id.borrow().clone()
2214 }
2215
2216 #[must_use]
2219 pub fn generation_id(&self) -> i32 {
2220 self.current_generation.load(Ordering::Relaxed)
2221 }
2222
2223 #[must_use]
2229 pub fn group_metadata(&self) -> ConsumerGroupMetadata {
2230 ConsumerGroupMetadata {
2231 group_id: self.group_id.clone(),
2232 generation_id: self.current_generation.load(Ordering::Relaxed),
2233 member_id: self.member_id(),
2234 group_instance_id: self.group_instance_id.clone(),
2235 }
2236 }
2237
2238 pub async fn assignment(&self) -> Vec<(String, i32)> {
2240 self.assigned.lock().await.clone()
2241 }
2242
2243 pub async fn at_log_end(&self) -> bool {
2246 let assigned = self.assigned.lock().await;
2247 if assigned.is_empty() {
2248 return false;
2249 }
2250 let positions = self.next_offsets.lock().await;
2251 let ends = self.end_offsets.lock().await;
2252 assigned.iter().all(|partition| {
2253 positions
2254 .get(partition)
2255 .filter(|position| !poll::is_reset_sentinel(**position))
2256 .zip(ends.get(partition))
2257 .is_some_and(|(position, end)| position >= end)
2258 })
2259 }
2260
2261 pub async fn close(self) -> Result<(), ConsumerError> {
2269 self.close_with(CloseOptions::builder().build()).await
2270 }
2271
2272 pub async fn client_instance_id(
2289 &self,
2290 timeout: Time,
2291 ) -> Result<Option<uuid::Uuid>, ConsumerError> {
2292 client_instance_id(&self.client, timeout).await
2293 }
2294
2295 #[tracing::instrument(
2317 name = "consumer.close",
2318 level = "info",
2319 skip_all,
2320 fields(group_id = %self.group_id, member_id = %self.member_id(), ?options),
2321 err(level = "info")
2322 )]
2323 pub async fn close_with(mut self, options: crate::CloseOptions) -> Result<(), ConsumerError> {
2326 let timeout = control::close_timeout(options)?.to_std();
2327 let started = Instant::now();
2328 let request_timeout = self.request_timeout.to_std();
2329 let coordinator_deadline = started + timeout.min(request_timeout);
2332 self.auto_commit_on_close(coordinator_deadline).await;
2335 self.wait_for_async_commits(coordinator_deadline).await;
2338 let listener_result = self.leave_prepare().await;
2342 self.close_operation.send_replace(control::CloseRequest {
2343 operation: options.group_membership_operation,
2344 });
2345 self.coordinator_shutdown.cancel();
2346 if let Some(h) = self.coordinator_handle.take() {
2347 let _ = h.await;
2348 }
2349 let left = timeout
2352 .saturating_sub(started.elapsed())
2353 .min(request_timeout);
2354 self.close_fetch_sessions(left).await;
2355 let left = timeout.saturating_sub(started.elapsed());
2357 let _ = time::timeout(left, self.client.close_telemetry()).await;
2358 listener_result
2359 }
2360
2361 pub(crate) async fn leave_prepare(&mut self) -> Result<(), ConsumerError> {
2364 if self.rebalance_listener.is_none() {
2365 return Ok(());
2366 }
2367 let pending = self.run_pending_listener_calls().await;
2368 let owned = self.assigned.lock().await.clone();
2369 if owned.is_empty() {
2370 return pending;
2371 }
2372 let kind = if self.member_id.borrow().is_empty() || *self.rebalance_pending.borrow() {
2373 rebalance_listener::ListenerCallKind::Lost
2374 } else {
2375 rebalance_listener::ListenerCallKind::Revoked
2376 };
2377 let result = self.run_listener(kind, &owned).await;
2378 pending.and(result)
2379 }
2380}
2381
2382#[cfg(test)]
2383impl Consumer {
2384 pub(crate) fn test_builder(
2385 client: krabka_client_core::Client,
2386 ) -> test_support::ConsumerTestBuilder {
2387 test_support::ConsumerTest::builder(client)
2388 }
2389}
2390
2391#[cfg(test)]
2392mod protocol_millis_tests {
2393 use assert2::check;
2394 use krabka_units::{Time, convert::TimeExt as _, millis, minutes, secs};
2395
2396 use super::protocol_millis_i32;
2397
2398 #[test]
2404 fn protocol_millis_truncates_rather_than_rounding() {
2405 for (_name, value, expected) in [
2406 ("whole default session timeout", secs(45), 45_000),
2407 ("whole default rebalance timeout", minutes(1), 60_000),
2408 ("whole millisecond", millis(37), 37),
2409 (
2410 "fractional millisecond truncates down",
2411 Time::from_secs_f64(0.0016),
2412 1,
2413 ),
2414 (
2415 "fractional millisecond that would round up",
2416 Time::from_secs_f64(1.9999),
2417 1_999,
2418 ),
2419 ("below one millisecond", Time::from_secs_f64(0.0009), 0),
2420 ] {
2421 check!(protocol_millis_i32(value) == expected);
2422 }
2423 }
2424
2425 #[test]
2429 fn protocol_millis_saturates_at_i32_max() {
2430 check!(protocol_millis_i32(Time::from_secs(10_000_000_000)) == i32::MAX);
2431 }
2432}
2433
2434#[cfg(test)]
2435mod consumer_retry_policy_tests {
2436 use assert2::{assert, check};
2437 use krabka_units::{Time, convert::TimeExt as _, millis, minutes, secs};
2438
2439 use super::ConsumerRetryPolicy;
2440
2441 #[test]
2442 fn consumer_retry_policy_defaults_and_overrides() {
2443 let defaults = ConsumerRetryPolicy::default();
2444 check!(defaults.startup_attempt_timeout() == secs(90));
2445 check!(defaults.startup_deadline() == minutes(5));
2446 check!(defaults.startup_initial_backoff() == millis(500));
2447 check!(defaults.startup_max_backoff() == millis(1000));
2448 check!(defaults.coordinator_retry_timeout() == secs(30));
2449 check!(defaults.coordinator_initial_backoff() == millis(100));
2450 check!(defaults.coordinator_max_backoff() == secs(1));
2451 check!(Ok(defaults) == ConsumerRetryPolicy::builder().build());
2452 check!(
2453 Ok(defaults)
2454 == ConsumerRetryPolicy::with_retry_backoff_max(
2455 super::DEFAULT_CONSUMER_RETRY_BACKOFF_MAX
2456 )
2457 );
2458
2459 let configured = ConsumerRetryPolicy::builder()
2460 .startup_attempt_timeout(secs(11))
2461 .startup_deadline(secs(12))
2462 .startup_initial_backoff(millis(13))
2463 .startup_max_backoff(millis(14))
2464 .coordinator_retry_timeout(secs(15))
2465 .coordinator_initial_backoff(millis(16))
2466 .coordinator_max_backoff(millis(17))
2467 .build()
2468 .expect("valid retry policy");
2469 check!(configured.startup_attempt_timeout() == secs(11));
2470 check!(configured.startup_deadline() == secs(12));
2471 check!(configured.startup_initial_backoff() == millis(13));
2472 check!(configured.startup_max_backoff() == millis(14));
2473 check!(configured.coordinator_retry_timeout() == secs(15));
2474 check!(configured.coordinator_initial_backoff() == millis(16));
2475 check!(configured.coordinator_max_backoff() == millis(17));
2476 }
2477
2478 #[test]
2483 fn with_retry_backoff_max_caps_startup_and_coordinator_backoff() {
2484 for (name, retry_backoff_max) in [
2485 ("Kafka's default", millis(1000)),
2486 ("custom, above both fixed initial backoffs", secs(3)),
2487 ("below both fixed initial backoffs", millis(100)),
2488 ("between the two fixed initial backoffs", millis(250)),
2489 ] {
2490 let policy = ConsumerRetryPolicy::with_retry_backoff_max(retry_backoff_max)
2491 .unwrap_or_else(|error| panic!("{name}: {error}"));
2492 check!(policy.startup_max_backoff() == retry_backoff_max);
2493 check!(policy.coordinator_max_backoff() == retry_backoff_max);
2494 check!(
2495 policy.startup_initial_backoff() <= retry_backoff_max,
2496 "{name}: startup initial backoff must not exceed retry_backoff_max"
2497 );
2498 check!(
2499 policy.coordinator_initial_backoff() <= retry_backoff_max,
2500 "{name}: coordinator initial backoff must not exceed retry_backoff_max"
2501 );
2502 }
2503 }
2504
2505 #[test]
2506 fn with_retry_backoff_max_rejects_invalid_values() {
2507 for retry_backoff_max in [
2508 Time::ZERO,
2509 Time::from_secs_f64(0.0005),
2510 Time::from_secs_f64(f64::INFINITY),
2511 ] {
2512 assert!(ConsumerRetryPolicy::with_retry_backoff_max(retry_backoff_max).is_err());
2513 }
2514 }
2515
2516 #[test]
2517 fn consumer_retry_policy_rejects_invalid_values() {
2518 for invalid in [
2519 Time::ZERO,
2520 Time::from_secs_f64(0.0005),
2521 Time::from_secs_f64(f64::INFINITY),
2522 ] {
2523 assert!(
2524 ConsumerRetryPolicy::builder()
2525 .startup_attempt_timeout(invalid)
2526 .build()
2527 .is_err()
2528 );
2529 assert!(
2530 ConsumerRetryPolicy::builder()
2531 .startup_deadline(invalid)
2532 .build()
2533 .is_err()
2534 );
2535 assert!(
2536 ConsumerRetryPolicy::builder()
2537 .startup_initial_backoff(invalid)
2538 .build()
2539 .is_err()
2540 );
2541 assert!(
2542 ConsumerRetryPolicy::builder()
2543 .startup_max_backoff(invalid)
2544 .build()
2545 .is_err()
2546 );
2547 assert!(
2548 ConsumerRetryPolicy::builder()
2549 .coordinator_retry_timeout(invalid)
2550 .build()
2551 .is_err()
2552 );
2553 assert!(
2554 ConsumerRetryPolicy::builder()
2555 .coordinator_initial_backoff(invalid)
2556 .build()
2557 .is_err()
2558 );
2559 assert!(
2560 ConsumerRetryPolicy::builder()
2561 .coordinator_max_backoff(invalid)
2562 .build()
2563 .is_err()
2564 );
2565 }
2566
2567 let invalid_orderings = [
2568 ConsumerRetryPolicy::builder()
2569 .startup_attempt_timeout(secs(13))
2570 .startup_deadline(secs(12))
2571 .build(),
2572 ConsumerRetryPolicy::builder()
2573 .startup_initial_backoff(millis(3))
2574 .startup_max_backoff(millis(2))
2575 .build(),
2576 ConsumerRetryPolicy::builder()
2577 .coordinator_initial_backoff(millis(3))
2578 .coordinator_max_backoff(millis(2))
2579 .build(),
2580 ];
2581 for policy in invalid_orderings {
2582 assert!(policy.is_err());
2583 }
2584 }
2585}
2586
2587#[cfg(test)]
2588mod security_arg_tests {
2589 use std::{fmt, future::Future, io, sync::atomic::AtomicBool};
2590
2591 use assert2::check;
2592 use krabka_client_core::{
2593 ClientError, MockBroker,
2594 security::{ClientSecurity, SaslCredentials},
2595 };
2596 use krabka_protocol::{
2597 Encode,
2598 owned::{
2599 api_versions_request, leave_group_request,
2600 leave_group_response::{self, LeaveGroupResponse},
2601 },
2602 };
2603 use krabka_security::ListenerProtocol;
2604
2605 use super::*;
2606
2607 async fn invalid_consumer_config<F, E>(build: F) -> E
2608 where
2609 F: Future<Output = Result<Consumer, E>>,
2610 E: fmt::Debug,
2611 {
2612 build.await.err().expect("invalid configuration")
2613 }
2614
2615 fn leave_group_response_at(version: i16) -> Vec<u8> {
2616 let mut buf = bytes::BytesMut::new();
2617 if version >= leave_group_response::FLEXIBLE_MIN {
2618 buf.extend_from_slice(&[0x00]);
2619 }
2620 LeaveGroupResponse::default()
2621 .encode(&mut buf, version)
2622 .unwrap();
2623 buf.to_vec()
2624 }
2625
2626 #[test]
2627 fn leave_group_timeout_uses_default_and_valid_override() {
2628 let default = ConsumerLeaveGroupTimeout::default();
2629 assert2::assert!(default.duration() == Duration::from_secs(5));
2630 assert2::assert!(default.milliseconds() == 5_000);
2631 assert2::assert!(Ok(default) == ConsumerLeaveGroupTimeout::new(Duration::from_secs(5)));
2632
2633 let timeout = ConsumerLeaveGroupTimeout::new(Duration::from_millis(37))
2634 .expect("positive whole milliseconds");
2635 assert2::assert!(timeout.duration() == Duration::from_millis(37));
2636 assert2::assert!(timeout.milliseconds() == 37);
2637 }
2638
2639 #[test]
2640 fn leave_group_timeout_validates_millisecond_boundaries() {
2641 assert2::assert!(ConsumerLeaveGroupTimeout::new(Duration::ZERO).is_err());
2642 assert2::assert!(
2643 ConsumerLeaveGroupTimeout::new(Duration::from_millis(1) + Duration::from_nanos(1))
2644 .is_err()
2645 );
2646 assert2::assert!(
2647 ConsumerLeaveGroupTimeout::new(Duration::from_millis(u64::MAX))
2648 .map(ConsumerLeaveGroupTimeout::milliseconds)
2649 == Ok(u64::MAX)
2650 );
2651 assert2::assert!(ConsumerLeaveGroupTimeout::new(Duration::from_secs(u64::MAX)).is_err());
2652 }
2653
2654 #[test]
2655 fn subscription_metadata_refresh_interval_uses_default_and_valid_override() {
2656 let default = ConsumerSubscriptionMetadataRefreshInterval::default();
2657 assert2::assert!(default.duration() == Duration::from_secs(5));
2658 assert2::assert!(default.milliseconds() == 5_000);
2659 assert2::assert!(
2660 Ok(default) == ConsumerSubscriptionMetadataRefreshInterval::new(Duration::from_secs(5))
2661 );
2662
2663 let interval = ConsumerSubscriptionMetadataRefreshInterval::new(Duration::from_millis(37))
2664 .expect("positive whole milliseconds");
2665 assert2::assert!(interval.duration() == Duration::from_millis(37));
2666 assert2::assert!(interval.milliseconds() == 37);
2667 }
2668
2669 #[test]
2670 fn subscription_metadata_refresh_interval_validates_millisecond_boundaries() {
2671 assert2::assert!(ConsumerSubscriptionMetadataRefreshInterval::new(Duration::ZERO).is_err());
2672 assert2::assert!(
2673 ConsumerSubscriptionMetadataRefreshInterval::new(
2674 Duration::from_millis(1) + Duration::from_nanos(1)
2675 )
2676 .is_err()
2677 );
2678 assert2::assert!(
2679 ConsumerSubscriptionMetadataRefreshInterval::new(Duration::from_millis(u64::MAX))
2680 .is_ok()
2681 );
2682 assert2::assert!(
2683 ConsumerSubscriptionMetadataRefreshInterval::new(Duration::from_secs(u64::MAX))
2684 .is_err()
2685 );
2686 }
2687
2688 #[tokio::test]
2689 async fn invalid_subscription_metadata_refresh_interval_fails_before_broker_lookup() {
2690 let error = invalid_consumer_config(
2691 Consumer::builder()
2692 .bootstrap("invalid.invalid:9092")
2693 .group_id("metadata-refresh-validation")
2694 .subscribe(["topic".to_owned()])
2695 .subscription_metadata_refresh_interval(krabka_units::secs(0))
2696 .build(),
2697 )
2698 .await;
2699
2700 assert2::assert!(
2701 error
2702 .to_string()
2703 .contains("consumer subscription metadata refresh interval")
2704 );
2705 }
2706
2707 #[tokio::test]
2710 async fn negative_sub_millisecond_times_fail_before_broker_lookup() {
2711 let negative = Time::from_secs_f64(-0.0001);
2712 let mut actual = Vec::new();
2713 for (name, default_api_timeout, metadata_max_age) in [
2714 (
2715 "default api timeout",
2716 negative,
2717 DEFAULT_CONSUMER_METADATA_MAX_AGE,
2718 ),
2719 (
2720 "metadata max age",
2721 DEFAULT_CONSUMER_DEFAULT_API_TIMEOUT,
2722 negative,
2723 ),
2724 ] {
2725 let build = Consumer::builder()
2726 .bootstrap("invalid.invalid:9092")
2727 .group_id("negative-times")
2728 .subscribe(["topic".to_owned()])
2729 .default_api_timeout(default_api_timeout)
2730 .metadata_max_age(metadata_max_age)
2731 .build();
2732 let error = timeout(Duration::from_secs(5), build)
2733 .await
2734 .map(|result| result.err().map(|error| error.to_string()));
2735 actual.push((name, error.ok().flatten()));
2736 }
2737 assert2::assert!(
2738 actual
2739 == vec![
2740 (
2741 "default api timeout",
2742 Some("invalid configuration: consumer default api timeout must not be negative".to_owned())
2743 ),
2744 (
2745 "metadata max age",
2746 Some("invalid configuration: consumer metadata max age must not be negative".to_owned())
2747 ),
2748 ]
2749 );
2750 }
2751
2752 #[tokio::test]
2756 async fn low_retry_backoff_max_clamps_initial_backoffs_instead_of_failing() {
2757 let mock = MockBroker::start(|_api_key, _version, _corr_id, _body| None)
2758 .await
2759 .unwrap();
2760
2761 let build = Consumer::builder()
2762 .bootstrap(mock.addr.to_string())
2763 .client_id("low-retry-backoff-max-consumer")
2764 .group_id("low-retry-backoff-max-group")
2765 .subscribe(vec!["orders".to_owned()])
2766 .request_timeout(krabka_units::millis(50))
2767 .retry_backoff_max(millis(100))
2768 .build();
2769 let res = timeout(Duration::from_millis(500), build).await;
2770
2771 mock.stop();
2772
2773 let Err(err) = res.expect("a valid low retry_backoff_max must not hang the build") else {
2774 panic!("silent broker must time out during build")
2775 };
2776 assert2::assert!(
2777 matches!(err, ConsumerError::Client(ClientError::Timeout(_))),
2778 "build must fail on the connection timeout, not on config validation: {err}"
2779 );
2780 }
2781
2782 #[tokio::test]
2785 async fn invalid_retry_backoff_max_fails_before_broker_lookup() {
2786 let build = Consumer::builder()
2787 .bootstrap("invalid.invalid:9092")
2788 .group_id("retry-backoff-max")
2789 .subscribe(["topic".to_owned()])
2790 .retry_backoff_max(Time::ZERO)
2791 .build();
2792 let error = timeout(Duration::from_secs(5), build)
2793 .await
2794 .expect("build must fail before any broker lookup")
2795 .err()
2796 .expect("a zero retry backoff max must be rejected");
2797 assert2::assert!(error.to_string().contains("backoff"));
2798 }
2799
2800 #[tokio::test]
2804 async fn the_subscription_of_a_build_is_checked_before_broker_lookup() {
2805 let mut actual = Vec::new();
2806 let mut wanted = Vec::new();
2807 for (name, topics, pattern, expected) in [
2808 (
2809 "a subscription without a group id",
2810 vec!["orders".to_owned()],
2811 None,
2812 "To use the group management or offset commit APIs, you must provide a valid group.id in the consumer configuration.",
2813 ),
2814 (
2815 "both",
2816 vec!["orders".to_owned()],
2817 Some(TopicPattern::new(|_| true)),
2818 "invalid configuration: subscribe and subscribe_pattern are mutually exclusive",
2819 ),
2820 (
2821 "empty topic name",
2822 vec![String::new()],
2823 None,
2824 "invalid configuration: Topic collection to subscribe to cannot contain null or empty topic",
2825 ),
2826 ] {
2827 let error = Consumer::builder()
2828 .bootstrap("invalid.invalid:9092")
2829 .maybe_group_id(
2830 (name != "a subscription without a group id")
2831 .then_some("subscription-validation"),
2832 )
2833 .subscribe(topics)
2834 .maybe_subscribe_pattern(pattern)
2835 .build()
2836 .await
2837 .err()
2838 .map(|error| error.to_string());
2839 actual.push((name, error));
2840 wanted.push((name, Some(expected.to_owned())));
2841 }
2842 assert2::assert!(actual == wanted);
2843 }
2844
2845 #[tokio::test]
2850 async fn the_consumer_group_protocol_options_are_checked_before_broker_lookup() {
2851 let mut actual = Vec::new();
2852 let mut wanted = Vec::new();
2853 for (name, protocol, assignor, regex, topics, expected) in [
2854 (
2855 "a remote assignor with the classic protocol",
2856 GroupProtocol::Classic,
2857 Some("uniform"),
2858 None,
2859 Vec::new(),
2860 "invalid configuration: group.remote.assignor cannot be set when group.protocol=classic",
2861 ),
2862 (
2863 "a regular expression with the classic protocol",
2864 GroupProtocol::Classic,
2865 None,
2866 Some("orders-.*"),
2867 Vec::new(),
2868 "invalid configuration: Subscribe to RE2/J pattern is not supported when using the CLASSIC protocol defined in config group.protocol",
2869 ),
2870 (
2871 "an empty regular expression",
2872 GroupProtocol::Consumer,
2873 None,
2874 Some(""),
2875 Vec::new(),
2876 "invalid argument: Topic pattern to subscribe to cannot be empty",
2877 ),
2878 (
2879 "a regular expression and topics",
2880 GroupProtocol::Consumer,
2881 None,
2882 Some("orders-.*"),
2883 vec!["orders".to_owned()],
2884 "invalid configuration: Subscription to topics, partitions and pattern are mutually exclusive",
2885 ),
2886 ] {
2887 let error = Consumer::builder()
2888 .bootstrap("invalid.invalid:9092")
2889 .group_id("protocol-validation")
2890 .subscribe(topics)
2891 .group_protocol(protocol)
2892 .maybe_group_remote_assignor(assignor)
2893 .maybe_subscribe_regex(regex)
2894 .build()
2895 .await
2896 .err()
2897 .map(|error| error.to_string());
2898 actual.push((name, error));
2899 wanted.push((name, Some(expected.to_owned())));
2900 }
2901 assert2::assert!(actual == wanted);
2902 }
2903
2904 #[tokio::test]
2905 async fn invalid_leave_group_timeout_fails_before_broker_lookup() {
2906 let error = invalid_consumer_config(
2907 Consumer::builder()
2908 .bootstrap("invalid.invalid:9092")
2909 .group_id("leave-validation")
2910 .subscribe(["topic".to_owned()])
2911 .leave_group_timeout(krabka_units::secs(0))
2912 .build(),
2913 )
2914 .await;
2915
2916 assert2::assert!(error.to_string().contains("consumer leave-group timeout"));
2917 }
2918
2919 #[tokio::test]
2920 async fn invalid_client_resource_policy_fails_before_broker_lookup() {
2921 let error = Consumer::builder()
2922 .bootstrap("invalid.invalid:9092")
2923 .group_id("client-policy-validation")
2924 .subscribe(["topic".to_owned()])
2925 .dispatch_queue_capacity(0)
2926 .build()
2927 .await
2928 .err()
2929 .expect("invalid configuration");
2930
2931 assert2::assert!(error.to_string().contains("client dispatch queue capacity"));
2932 }
2933
2934 #[tokio::test]
2935 async fn startup_member_cleanup_leaves_the_group_for_a_dynamic_member_only() {
2936 let mut actual = Vec::new();
2937 let mut wanted = Vec::new();
2938 for (name, group_instance_id, expected) in [
2939 ("dynamic member", None, true),
2940 ("static member", Some("instance-a".to_owned()), false),
2941 ] {
2942 let saw_leave = Arc::new(AtomicBool::new(false));
2943 let saw_leave_in_mock = Arc::clone(&saw_leave);
2944 let mock = MockBroker::start(move |api_key, version, _corr_id, _body| {
2945 if api_key == api_versions_request::API_KEY {
2946 Some(test_support::leave_group_api_versions())
2947 } else if api_key == leave_group_request::API_KEY {
2948 saw_leave_in_mock.store(true, Ordering::SeqCst);
2949 Some(leave_group_response_at(version))
2950 } else {
2951 None
2952 }
2953 })
2954 .await
2955 .unwrap();
2956
2957 let client = Client::builder()
2958 .bootstrap(mock.addr.to_string())
2959 .request_timeout(krabka_units::millis(100))
2960 .build()
2961 .await
2962 .unwrap();
2963 let coordinator_id = AtomicI32::new(0);
2964
2965 leave_startup_member(
2966 &client,
2967 &coordinator_id,
2968 "group-a",
2969 "member-a",
2970 group_instance_id,
2971 krabka_units::millis(37),
2972 )
2973 .await;
2974
2975 mock.stop();
2976 actual.push((name, saw_leave.load(Ordering::SeqCst)));
2977 wanted.push((name, expected));
2978 }
2979 assert2::assert!(actual == wanted);
2980 }
2981
2982 #[tokio::test]
2983 async fn startup_member_cleanup_bounds_stalled_leave_with_configured_timeout() {
2984 let fixture = test_support::StalledLeaveGroup::start().await;
2985
2986 timeout(
2987 Duration::from_secs(1),
2988 leave_startup_member(
2989 &fixture.client,
2990 &fixture.coordinator_id,
2991 "group-a",
2992 "member-a",
2993 None,
2994 krabka_units::millis(37),
2995 ),
2996 )
2997 .await
2998 .expect("configured leave deadline bounds cleanup");
2999
3000 fixture.broker.stop();
3001 assert2::assert!(fixture.saw_leave.load(Ordering::SeqCst));
3002 }
3003
3004 #[test]
3009 fn initial_join_protocols_follow_the_assignor_list() {
3010 use builder::DecodedSubscription;
3011
3012 let protocols = initial_join_protocols(
3013 &[
3014 Assignor::Range,
3015 Assignor::Sticky,
3016 Assignor::CooperativeSticky,
3017 ],
3018 &["topic".into()],
3019 Some("rack-a"),
3020 )
3021 .unwrap();
3022 let decoded: Vec<(String, DecodedSubscription)> = protocols
3023 .iter()
3024 .map(|(name, metadata)| (name.clone(), builder::decode_subscription(metadata)))
3025 .collect();
3026 let subscription = |user_data: Option<Bytes>| DecodedSubscription {
3027 topics: vec!["topic".into()],
3028 owned: Vec::new(),
3029 generation_id: -1,
3030 rack_id: Some("rack-a".into()),
3031 user_data,
3032 };
3033 check!(
3034 decoded
3035 == vec![
3036 ("range".to_owned(), subscription(None)),
3037 ("sticky".to_owned(), subscription(None)),
3038 (
3039 "cooperative-sticky".to_owned(),
3040 subscription(Some(Bytes::from_static(&[0xff, 0xff, 0xff, 0xff]))),
3041 ),
3042 ]
3043 );
3044 }
3045
3046 #[test]
3047 fn startup_join_fields_preserve_member_timeouts_protocols_and_empty_reason() {
3048 let metadata = bytes::Bytes::from_static(b"metadata");
3049 let fields = startup_join_fields(
3050 "group-a",
3051 Some("instance-a"),
3052 vec![
3053 ("range".to_owned(), metadata.clone()),
3054 ("cooperative-sticky".to_owned(), metadata.clone()),
3055 ],
3056 (45_000, 60_000),
3057 );
3058
3059 assert2::assert!(fields.group_id == "group-a");
3060 assert2::assert!(fields.group_instance_id.as_deref() == Some("instance-a"));
3061 assert2::assert!(fields.session_timeout_ms == 45_000);
3062 assert2::assert!(fields.rebalance_timeout_ms == 60_000);
3063 assert2::assert!(
3064 fields.protocols
3065 == vec![
3066 ("range".to_owned(), metadata.clone()),
3067 ("cooperative-sticky".to_owned(), metadata),
3068 ]
3069 );
3070 assert2::assert!(fields.reason.is_empty());
3071 }
3072
3073 #[test]
3074 fn first_join_member_id_accepts_required_or_success_and_rejects_errors() {
3075 enum Expected<'a> {
3076 Member(&'a str),
3077 Server(i16),
3078 RebalanceFailed,
3079 }
3080 for (_name, error_code, member_id, expected) in [
3081 (
3082 "member id required",
3083 79,
3084 "member-a",
3085 Expected::Member("member-a"),
3086 ),
3087 ("success", 0, "member-b", Expected::Member("member-b")),
3088 ("server error", 42, "member-c", Expected::Server(42)),
3089 ("empty member", 0, "", Expected::RebalanceFailed),
3090 ] {
3091 let response = JoinGroupResponse {
3092 error_code,
3093 member_id: member_id.into(),
3094 ..Default::default()
3095 };
3096 let actual = first_join_member_id(&response);
3097 let matches = match expected {
3098 Expected::Member(expected) => matches!(actual, Ok(actual) if actual == expected),
3099 Expected::Server(expected) => {
3100 matches!(actual, Err(ConsumerError::Server(code)) if code == expected)
3101 }
3102 Expected::RebalanceFailed => {
3103 matches!(actual, Err(ConsumerError::RebalanceFailed(_)))
3104 }
3105 };
3106 assert2::assert!(matches);
3107 }
3108 }
3109
3110 #[test]
3111 fn is_subscribed_topic_matches_exact_topic_names() {
3112 let subscribe = vec!["orders".to_owned(), "payments".to_owned()];
3113
3114 for (name, expected) in [
3115 ("orders", true),
3116 ("payments", true),
3117 ("shipments", false),
3118 ("order", false),
3119 ] {
3120 assert2::assert!(is_subscribed_topic(&subscribe, name) == expected);
3121 }
3122 }
3123
3124 #[test]
3125 fn is_group_leader_matches_exact_member_id() {
3126 for (_name, leader, member_id, expected) in [
3127 ("exact leader", "member-a", "member-a", true),
3128 ("different member", "member-a", "member-b", false),
3129 ("empty member", "member-a", "", false),
3130 ] {
3131 assert2::assert!(is_group_leader(leader, member_id) == expected);
3132 }
3133 }
3134
3135 #[test]
3136 fn build_sync_assignment_preserves_member_and_assignment_payload() {
3137 let assignment =
3138 coordinator::build_sync_group_assignment("member-a".into(), &[("orders".into(), 3)])
3139 .unwrap();
3140 let decoded = decode_assignment(&assignment.assignment);
3141
3142 assert2::assert!(
3143 (assignment.member_id.as_str(), decoded) == ("member-a", vec![("orders".into(), 3)])
3144 );
3145 }
3146
3147 #[test]
3148 fn offset_prime_helpers_preserve_assignment_presence_offsets_and_epochs() {
3149 for (_name, partitions, expected) in [
3150 ("empty assignment", vec![], false),
3151 ("assigned partition", vec![("orders".to_owned(), 0)], true),
3152 ] {
3153 assert2::assert!(has_assigned_partitions(&partitions) == expected);
3154 }
3155
3156 test_support::assert_starting_offsets(starting_offset);
3157
3158 let position = primed_position(9);
3159 assert2::assert!(
3160 position
3161 == position::PartitionPosition {
3162 offset_epoch: krabka_ids::LeaderEpoch(9),
3163 leader_id: -1,
3164 leader_epoch: krabka_ids::LeaderEpoch(-1),
3165 awaiting_validation: false,
3166 }
3167 );
3168 }
3169
3170 #[tokio::test]
3171 async fn consumer_builder_uses_request_timeout_for_initial_connect_handshake() {
3172 let mock = MockBroker::start(|_api_key, _version, _corr_id, _body| None)
3173 .await
3174 .unwrap();
3175
3176 let build = Consumer::builder()
3177 .bootstrap(mock.addr.to_string())
3178 .client_id("timeout-consumer")
3179 .group_id("timeout-group")
3180 .subscribe(vec!["orders".to_owned()])
3181 .request_timeout(krabka_units::millis(100))
3182 .build();
3183 let res = timeout(Duration::from_millis(500), build).await;
3184
3185 mock.stop();
3186
3187 let Err(err) = res.expect("consumer build should not retain the 30s connect timeout")
3188 else {
3189 panic!("silent broker must time out during build")
3190 };
3191 assert2::assert!(matches!(
3192 err,
3193 ConsumerError::Client(ClientError::Timeout(d))
3194 if d == krabka_units::millis(100)
3195 ));
3196 }
3197
3198 #[tokio::test]
3202 async fn a_refused_start_records_no_error() {
3203 use krabka_client_core::mock_test_support::recorded_levels;
3204
3205 let (invalid, levels) = recorded_levels(Box::pin(
3206 Consumer::builder()
3207 .bootstrap("127.0.0.1:1")
3208 .group_id("log-level-group")
3209 .subscribe(vec!["orders".to_owned()])
3210 .fetch_min(0)
3211 .build(),
3212 ))
3213 .await;
3214 assert2::assert!(matches!(invalid, Err(ConsumerError::InvalidConfig(_))));
3215 check!(levels == vec![tracing::Level::INFO]);
3216
3217 let mock = MockBroker::start(|_api_key, _version, _corr_id, _body| None)
3218 .await
3219 .unwrap();
3220 let (silent, levels) = recorded_levels(Box::pin(
3221 Consumer::builder()
3222 .bootstrap(mock.addr.to_string())
3223 .group_id("log-level-group")
3224 .subscribe(vec!["orders".to_owned()])
3225 .request_timeout(millis(100))
3226 .build(),
3227 ))
3228 .await;
3229 mock.stop();
3230 assert2::assert!(silent.is_err());
3231 check!(!levels.is_empty());
3232 check!(!levels.contains(&tracing::Level::ERROR), "{levels:?}");
3233 }
3234
3235 #[tokio::test]
3236 async fn consumer_builder_rejects_non_positive_fetch_budgets_before_network_io() {
3237 let min = Consumer::builder()
3238 .bootstrap("127.0.0.1:1")
3239 .group_id("timeout-group")
3240 .subscribe(vec!["orders".to_owned()])
3241 .fetch_min(0)
3242 .build()
3243 .await;
3244 assert2::assert!(matches!(min, Err(ConsumerError::InvalidConfig(_))));
3245
3246 let max = Consumer::builder()
3247 .bootstrap("127.0.0.1:1")
3248 .group_id("timeout-group")
3249 .subscribe(vec!["orders".to_owned()])
3250 .fetch_max(0)
3251 .build()
3252 .await;
3253 assert2::assert!(matches!(max, Err(ConsumerError::InvalidConfig(_))));
3254
3255 let partition_max = Consumer::builder()
3256 .bootstrap("127.0.0.1:1")
3257 .group_id("timeout-group")
3258 .subscribe(vec!["orders".to_owned()])
3259 .fetch_partition_max(0)
3260 .build()
3261 .await;
3262 assert2::assert!(matches!(
3263 partition_max,
3264 Err(ConsumerError::InvalidConfig(_))
3265 ));
3266
3267 let inverted = Consumer::builder()
3268 .bootstrap("127.0.0.1:1")
3269 .group_id("timeout-group")
3270 .subscribe(vec!["orders".to_owned()])
3271 .fetch_min(2)
3272 .fetch_max(1)
3273 .build()
3274 .await;
3275 assert2::assert!(matches!(
3276 inverted,
3277 Err(ConsumerError::InvalidConfig(message))
3278 if message == "consumer fetch min must not exceed consumer fetch max"
3279 ));
3280 }
3281
3282 #[test]
3283 fn consumer_fetch_byte_settings_validate_and_convert_to_uom() {
3284 for value in [1, i32::MAX] {
3285 let size = ByteSize::from_bytes_i64(i64::from(value));
3286 let max = ConsumerFetchMaxBytes::try_from(size).expect("positive fetch maximum");
3287 let partition_max = ConsumerFetchPartitionMaxBytes::try_from(size)
3288 .expect("positive partition fetch maximum");
3289
3290 check!(max.bytes() == value);
3291 check!(partition_max.bytes() == value);
3292 check!(max.size().bytes_i32() == value);
3293 check!(partition_max.size().bytes_i32() == value);
3294 }
3295
3296 for invalid in [
3297 ByteSize::ZERO,
3298 ByteSize::from_bytes_f64(-1.0),
3299 ByteSize::from_bytes_f64(1.5),
3300 ByteSize::from_bytes_f64(f64::from(i32::MAX) + 1.0),
3301 ] {
3302 check!(ConsumerFetchMaxBytes::try_from(invalid).is_err());
3303 check!(ConsumerFetchPartitionMaxBytes::try_from(invalid).is_err());
3304 }
3305 }
3306
3307 async fn test_consumer() -> Consumer {
3308 let client = Client::builder()
3309 .bootstrap("127.0.0.1:1")
3310 .client_id("test-client")
3311 .build()
3312 .await
3313 .unwrap();
3314 Consumer::test_builder(client)
3315 .coordinator_id(Arc::new(AtomicI32::new(3)))
3316 .generation_id(7)
3317 .commit_identity(test_support::orders_partition_commit_identity(7))
3318 .group_instance_id("instance-a")
3319 .subscription(subscription::shared(
3320 vec!["orders".into(), "payments".into()],
3321 None,
3322 true,
3323 ))
3324 .assigned(vec![("orders".into(), 0)])
3325 .build()
3326 }
3327
3328 #[tokio::test]
3329 async fn accessors_return_consumer_identity_subscription_and_assignment() {
3330 let consumer = test_consumer().await;
3331
3332 check!(
3333 (
3334 consumer.group_id(),
3335 consumer.member_id(),
3336 consumer.generation_id(),
3337 consumer.subscription(),
3338 consumer.assignment().await,
3339 ) == (
3340 "group-a",
3341 "member-a".to_owned(),
3342 7,
3343 vec!["orders".to_owned(), "payments".to_owned()],
3344 vec![("orders".into(), 0)],
3345 )
3346 );
3347
3348 let metadata = consumer.group_metadata();
3349 assert2::assert!(
3350 metadata
3351 == ConsumerGroupMetadata {
3352 group_id: "group-a".into(),
3353 generation_id: 7,
3354 member_id: "member-a".into(),
3355 group_instance_id: Some("instance-a".into()),
3356 }
3357 );
3358 }
3359
3360 #[tokio::test]
3361 async fn log_end_requires_every_assigned_position_to_reach_a_reported_end() {
3362 let consumer = test_consumer().await;
3363 assert2::assert!(!consumer.at_log_end().await);
3364
3365 consumer
3366 .next_offsets
3367 .lock()
3368 .await
3369 .insert(("orders".into(), 0), 12);
3370 consumer
3371 .end_offsets
3372 .lock()
3373 .await
3374 .insert(("orders".into(), 0), 12);
3375 assert2::assert!(consumer.at_log_end().await);
3376
3377 consumer.seek("orders", 0, 0).await.unwrap();
3378 let end = consumer
3381 .end_offsets
3382 .lock()
3383 .await
3384 .get(&("orders".to_owned(), 0))
3385 .copied();
3386 assert2::assert!((consumer.at_log_end().await, end) == (false, Some(12)));
3387 }
3388
3389 #[tokio::test]
3397 async fn generation_tracks_coordinator_rejoins_via_shared_atomic() {
3398 let consumer = test_consumer().await;
3399 assert2::assert!(
3400 (
3401 consumer.generation_id(),
3402 consumer.group_metadata().generation_id
3403 ) == (7, 7)
3404 );
3405
3406 consumer.current_generation.store(11, Ordering::Relaxed);
3408
3409 assert2::assert!(
3410 (
3411 consumer.generation_id(),
3412 consumer.group_metadata().generation_id
3413 ) == (11, 11)
3414 );
3415 }
3416
3417 #[tokio::test]
3418 async fn close_cancels_shutdown_token_even_without_spawned_handle() {
3419 let consumer = test_consumer().await;
3420 let shutdown = consumer.coordinator_shutdown.clone();
3421
3422 consumer.close().await.unwrap();
3423
3424 assert2::assert!(shutdown.is_cancelled());
3425 }
3426
3427 #[test]
3430 fn retriable_error_classification() {
3431 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3432
3433 use krabka_client_core::ClientError;
3434
3435 let transient_codes: &[i16] = &[14, 15, 16, 22, 25, 27, 79];
3437 for &code in transient_codes {
3438 assert2::assert!(is_retriable_consumer_start_error(&ConsumerError::Server(
3439 code
3440 )));
3441 }
3442
3443 assert2::assert!(!is_retriable_consumer_start_error(&ConsumerError::Server(
3445 42
3446 )));
3447
3448 for (_name, error, expected) in [
3449 (
3451 "disconnected",
3452 ConsumerError::Client(ClientError::Disconnected),
3453 true,
3454 ),
3455 (
3460 "timeout",
3461 ConsumerError::Client(ClientError::Timeout(krabka_units::secs(1))),
3462 false,
3463 ),
3464 (
3465 "startup after join",
3466 ConsumerError::StartupAfterJoin(Box::new(ConsumerError::Client(
3467 ClientError::Timeout(krabka_units::secs(1)),
3468 ))),
3469 true,
3470 ),
3471 (
3472 "connect refused",
3473 ConsumerError::Client(ClientError::Connect {
3474 addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9092),
3475 source: io::Error::new(io::ErrorKind::ConnectionRefused, "refused"),
3476 }),
3477 false,
3478 ),
3479 ("not subscribed", ConsumerError::NotSubscribed, false),
3481 (
3482 "invalid config",
3483 ConsumerError::InvalidConfig("group_id required".into()),
3484 false,
3485 ),
3486 (
3487 "incompatible version",
3488 ConsumerError::Client(ClientError::IncompatibleVersion {
3489 api_key: 0,
3490 broker_min: 0,
3491 broker_max: 5,
3492 client_min: 7,
3493 client_max: 10,
3494 }),
3495 false,
3496 ),
3497 ] {
3498 assert2::assert!(is_retriable_consumer_start_error(&error) == expected);
3499 }
3500 }
3501
3502 #[tokio::test]
3503 async fn consumer_builder_accepts_security() {
3504 let security = ClientSecurity {
3505 protocol: ListenerProtocol::SaslPlaintext,
3506 tls: None,
3507 sasl: Some(SaslCredentials::Plain {
3508 username: "u".into(),
3509 password: "p".into(),
3510 }),
3511 sasl_host: None,
3512 };
3513 let res = Consumer::builder()
3517 .bootstrap("127.0.0.1:1")
3518 .group_id("g")
3519 .subscribe(vec!["t".to_owned()])
3520 .security(security)
3521 .build()
3522 .await;
3523 assert2::assert!(res.is_err());
3524 }
3525}
3526
3527#[cfg(test)]
3528mod auto_commit_tests {
3529 use std::{
3530 collections::VecDeque,
3531 future::Future,
3532 sync::atomic::{AtomicBool, AtomicI16},
3533 };
3534
3535 use bytes::Buf as _;
3536 use krabka_client_core::{
3537 MockBroker,
3538 mock_test_support::{api_versions_response_ranges, offset_commit_response},
3539 };
3540 use krabka_protocol::{
3541 Decode as _, Encode, UnknownTaggedFields,
3542 owned::{
3543 api_versions_request, fetch_request,
3544 fetch_response::FetchResponse,
3545 find_coordinator_request,
3546 find_coordinator_response::FindCoordinatorResponse,
3547 heartbeat_request,
3548 heartbeat_response::HeartbeatResponse,
3549 join_group_request::{self, JoinGroupRequest},
3550 leave_group_request::{self, LeaveGroupRequest},
3551 leave_group_response::LeaveGroupResponse,
3552 list_offsets_request, metadata_request,
3553 metadata_response::MetadataResponse,
3554 offset_commit_request::{
3555 self, OffsetCommitRequest, OffsetCommitRequestPartition, OffsetCommitRequestTopic,
3556 },
3557 offset_fetch_request,
3558 offset_fetch_response::OffsetFetchResponse,
3559 sync_group_request,
3560 },
3561 };
3562 use tokio::time::advance;
3563
3564 use super::*;
3565
3566 const GROUP: &str = "group-a";
3567 pub(super) const MEMBER: &str = "member-a";
3568 pub(super) const TOPIC: &str = "orders";
3569 const REBALANCE_IN_PROGRESS: i16 = 27;
3571 const AUTO_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
3572 const SESSION_TIMEOUT: Duration = Duration::from_secs(45);
3573 const REBALANCE_WAIT: Duration = Duration::from_secs(30);
3576
3577 #[derive(Clone, Debug, PartialEq)]
3580 pub(super) enum GroupRequest {
3581 OffsetCommit(OffsetCommitRequest),
3582 JoinGroup,
3583 SyncGroup,
3584 LeaveGroup,
3585 CommitSyncReturned,
3587 SyncGroupLate,
3590 CloseTimedOut,
3592 SessionExpired,
3595 }
3596
3597 #[derive(Clone, Copy, Debug)]
3599 pub(super) enum CommitReply {
3600 Drop,
3602 Error(i16),
3604 }
3605
3606 const API_VERSIONS: [(i16, i16, i16); 11] = [
3610 (api_versions_request::API_KEY, 0, 3),
3611 (metadata_request::API_KEY, 0, 8),
3612 (find_coordinator_request::API_KEY, 0, 2),
3613 (join_group_request::API_KEY, 0, 8),
3614 (sync_group_request::API_KEY, 0, 3),
3615 (heartbeat_request::API_KEY, 0, 3),
3616 (leave_group_request::API_KEY, 0, 5),
3617 (offset_commit_request::API_KEY, 2, 7),
3618 (offset_fetch_request::API_KEY, 1, 5),
3619 (fetch_request::API_KEY, 4, 11),
3620 (list_offsets_request::API_KEY, 1, 5),
3621 ];
3622
3623 fn encoded_api_versions() -> Vec<u8> {
3624 encode(
3625 &api_versions_response_ranges(API_VERSIONS.iter().copied()),
3626 0,
3627 )
3628 }
3629
3630 pub(super) struct MockCoordinator {
3634 protocol: &'static str,
3635 requests: StdMutex<Vec<GroupRequest>>,
3636 pub(super) heartbeat_error: AtomicI16,
3639 assignments: StdMutex<VecDeque<Vec<(String, i32)>>>,
3642 generation: AtomicI32,
3643 pub(super) commit_replies: StdMutex<VecDeque<CommitReply>>,
3646 rebalance_on_commit: AtomicBool,
3649 pub(super) last_heartbeat: StdMutex<Instant>,
3651 drop_heartbeats: AtomicBool,
3654 pub(super) joins: StdMutex<Vec<JoinGroupRequest>>,
3656 pub(super) leaves: StdMutex<Vec<LeaveGroupRequest>>,
3658 pub(super) drop_leaves: AtomicBool,
3660 }
3661
3662 pub(super) async fn mock_coordinator_broker(coordinator: &Arc<MockCoordinator>) -> MockBroker {
3663 let in_mock = Arc::clone(coordinator);
3664 MockBroker::start(move |api_key, version, _corr_id, body| {
3665 in_mock.respond(api_key, version, body)
3666 })
3667 .await
3668 .unwrap()
3669 }
3670
3671 pub(super) fn single_partition_startup(topic_partition_count: i32) -> StartupState {
3672 StartupState::builder()
3673 .generation_id(1)
3674 .assigned_partitions(vec![partition(0)])
3675 .next_offsets(maplit::hashmap! {partition(0) => 12})
3676 .topic_partitions(maplit::hashmap! {TOPIC.to_owned() => topic_partition_count})
3677 .build()
3678 }
3679
3680 pub(super) async fn start_consumer_with_startup(
3681 config: StartConfig,
3682 startup: StartupState,
3683 ) -> Consumer {
3684 start_test_consumer(
3685 TestConsumerStart::builder()
3686 .bootstrap(config.bootstrap.clone())
3687 .config(config)
3688 .startup(startup)
3689 .build(),
3690 )
3691 .await
3692 }
3693
3694 pub(super) async fn start_single_partition_consumer(config: StartConfig) -> Consumer {
3695 start_consumer_with_startup(config, single_partition_startup(1)).await
3696 }
3697
3698 pub(super) fn two_partition_startup() -> StartupState {
3699 StartupState::builder()
3700 .generation_id(1)
3701 .assigned_partitions(vec![partition(0), partition(1)])
3702 .next_offsets(maplit::hashmap! {partition(0) => 12, partition(1) => 7})
3703 .topic_partitions(maplit::hashmap! {TOPIC.to_owned() => 2})
3704 .build()
3705 }
3706
3707 fn encode(response: &impl Encode, version: i16) -> Vec<u8> {
3708 let mut buf = bytes::BytesMut::new();
3709 response.encode(&mut buf, version).expect("encode response");
3710 buf.to_vec()
3711 }
3712
3713 impl MockCoordinator {
3714 pub(super) fn range_partition(index: i32) -> Arc<Self> {
3715 Self::new(Assignor::Range, vec![vec![partition(index)]])
3716 }
3717
3718 pub(super) fn new(assignor: Assignor, assignments: Vec<Vec<(String, i32)>>) -> Arc<Self> {
3721 Arc::new(Self {
3722 protocol: assignor.protocol_name(),
3723 requests: StdMutex::new(Vec::new()),
3724 heartbeat_error: AtomicI16::new(0),
3725 assignments: StdMutex::new(assignments.into()),
3726 generation: AtomicI32::new(1),
3727 commit_replies: StdMutex::new(VecDeque::new()),
3728 rebalance_on_commit: AtomicBool::new(false),
3729 last_heartbeat: StdMutex::new(Instant::now()),
3730 drop_heartbeats: AtomicBool::new(false),
3731 joins: StdMutex::new(Vec::new()),
3732 leaves: StdMutex::new(Vec::new()),
3733 drop_leaves: AtomicBool::new(false),
3734 })
3735 }
3736
3737 pub(super) fn set_heartbeat_error(&self, error_code: i16) {
3738 self.heartbeat_error.store(error_code, Ordering::SeqCst);
3739 }
3740
3741 fn record(&self, request: GroupRequest) {
3742 self.requests.lock().expect("requests lock").push(request);
3743 }
3744
3745 pub(super) fn requests(&self) -> Vec<GroupRequest> {
3746 self.requests.lock().expect("requests lock").clone()
3747 }
3748
3749 pub(super) fn sync_groups(&self) -> usize {
3750 self.requests()
3751 .iter()
3752 .filter(|request| **request == GroupRequest::SyncGroup)
3753 .count()
3754 }
3755
3756 pub(super) fn respond(
3757 &self,
3758 api_key: i16,
3759 version: i16,
3760 mut body: &[u8],
3761 ) -> Option<Vec<u8>> {
3762 match api_key {
3763 api_versions_request::API_KEY => Some(encoded_api_versions()),
3764 metadata_request::API_KEY => Some(encode(&MetadataResponse::default(), version)),
3765 heartbeat_request::API_KEY => {
3766 *self.last_heartbeat.lock().expect("last heartbeat lock") = Instant::now();
3767 let error_code = self.heartbeat_error.swap(0, Ordering::SeqCst);
3768 if error_code == 0 && self.drop_heartbeats.load(Ordering::SeqCst) {
3769 return None;
3770 }
3771 Some(encode(
3772 &HeartbeatResponse {
3773 error_code,
3774 ..Default::default()
3775 },
3776 version,
3777 ))
3778 }
3779 join_group_request::API_KEY => {
3780 let mut last_heartbeat =
3781 self.last_heartbeat.lock().expect("last heartbeat lock");
3782 if last_heartbeat.elapsed() > SESSION_TIMEOUT {
3783 self.record(GroupRequest::SessionExpired);
3784 }
3785 *last_heartbeat = Instant::now();
3786 drop(last_heartbeat);
3787 self.record(GroupRequest::JoinGroup);
3788 let client_id_len = body.get_i16();
3789 body.advance(usize::try_from(client_id_len.max(0)).expect("client id length"));
3790 let flexible = version >= join_group_request::FLEXIBLE_MIN;
3791 if flexible {
3792 body.advance(1);
3794 }
3795 self.joins
3796 .lock()
3797 .expect("joins lock")
3798 .push(JoinGroupRequest::decode(&mut body, version).expect("decode join"));
3799 let mut response = if flexible { vec![0] } else { Vec::new() };
3800 response.extend(encode(
3801 &JoinGroupResponse {
3802 generation_id: self.generation.fetch_add(1, Ordering::SeqCst) + 1,
3803 protocol_name: Some(self.protocol.to_owned()),
3804 protocol_type: Some("consumer".into()),
3805 leader: "leader".into(),
3806 member_id: MEMBER.into(),
3807 ..Default::default()
3808 },
3809 version,
3810 ));
3811 Some(response)
3812 }
3813 sync_group_request::API_KEY => {
3814 self.record(GroupRequest::SyncGroup);
3815 let mut assignments = self.assignments.lock().expect("assignments lock");
3816 let assignment = if assignments.len() > 1 {
3817 assignments.pop_front().expect("assignment")
3818 } else {
3819 assignments.front().cloned().expect("assignment")
3820 };
3821 drop(assignments);
3822 Some(encode(
3823 &SyncGroupResponse {
3824 assignment: builder::encode_assignment(&assignment).unwrap(),
3825 ..Default::default()
3826 },
3827 version,
3828 ))
3829 }
3830 fetch_request::API_KEY => Some(encode(&FetchResponse::default(), version)),
3832 list_offsets_request::API_KEY => {
3835 Some(test_support::list_offsets_mock_response(&mut body, version))
3836 }
3837 offset_fetch_request::API_KEY => {
3839 Some(encode(&OffsetFetchResponse::default(), version))
3840 }
3841 leave_group_request::API_KEY => {
3842 self.record(GroupRequest::LeaveGroup);
3843 let client_id_len = body.get_i16();
3844 body.advance(usize::try_from(client_id_len.max(0)).expect("client id length"));
3845 let flexible = version >= leave_group_request::FLEXIBLE_MIN;
3846 if flexible {
3847 body.advance(1);
3849 }
3850 self.leaves
3851 .lock()
3852 .expect("leaves lock")
3853 .push(LeaveGroupRequest::decode(&mut body, version).expect("decode leave"));
3854 if self.drop_leaves.load(Ordering::SeqCst) {
3855 return None;
3856 }
3857 let mut response = if flexible { vec![0] } else { Vec::new() };
3858 response.extend(encode(&LeaveGroupResponse::default(), version));
3859 Some(response)
3860 }
3861 find_coordinator_request::API_KEY => Some(encode(
3862 &FindCoordinatorResponse {
3863 node_id: 0,
3864 ..Default::default()
3865 },
3866 version,
3867 )),
3868 offset_commit_request::API_KEY => self.respond_offset_commit(version, body),
3869 _ => None,
3870 }
3871 }
3872
3873 fn respond_offset_commit(&self, version: i16, mut body: &[u8]) -> Option<Vec<u8>> {
3874 let client_id_len = body.get_i16();
3875 body.advance(usize::try_from(client_id_len.max(0)).expect("client id length"));
3876 let mut request =
3877 OffsetCommitRequest::decode(&mut body, version).expect("decode commit");
3878 request.topics.sort_by(|a, b| a.name.cmp(&b.name));
3879 for topic in &mut request.topics {
3880 topic.partitions.sort_by_key(|p| p.partition_index);
3881 }
3882 if self.rebalance_on_commit.swap(false, Ordering::SeqCst) {
3883 self.heartbeat_error
3884 .store(REBALANCE_IN_PROGRESS, Ordering::SeqCst);
3885 }
3886 let reply = self
3887 .commit_replies
3888 .lock()
3889 .expect("commit replies lock")
3890 .pop_front();
3891 let error_code = match reply {
3892 None => 0,
3893 Some(CommitReply::Error(code)) => code,
3894 Some(CommitReply::Drop) => {
3895 self.record(GroupRequest::OffsetCommit(request));
3896 return None;
3897 }
3898 };
3899 let response = offset_commit_response(&request, |_| error_code);
3900 self.record(GroupRequest::OffsetCommit(request));
3901 Some(encode(&response, version))
3902 }
3903 }
3904
3905 pub(super) fn partition(index: i32) -> (String, i32) {
3906 (TOPIC.to_owned(), index)
3907 }
3908
3909 fn commit(generation: i32, offsets: &[(i32, i64, i32)]) -> GroupRequest {
3912 GroupRequest::OffsetCommit(OffsetCommitRequest {
3913 group_id: GROUP.into(),
3914 generation_id_or_member_epoch: generation,
3915 member_id: MEMBER.into(),
3916 group_instance_id: None,
3917 retention_time_ms: -1,
3918 topics: vec![OffsetCommitRequestTopic {
3919 name: TOPIC.into(),
3920 partitions: offsets
3921 .iter()
3922 .map(
3923 |(partition_index, committed_offset, committed_leader_epoch)| {
3924 OffsetCommitRequestPartition {
3925 partition_index: *partition_index,
3926 committed_offset: *committed_offset,
3927 committed_leader_epoch: *committed_leader_epoch,
3928 committed_metadata: Some(String::new()),
3929 unknown_tagged_fields: UnknownTaggedFields::default(),
3930 }
3931 },
3932 )
3933 .collect(),
3934 ..Default::default()
3935 }],
3936 unknown_tagged_fields: UnknownTaggedFields::default(),
3937 })
3938 }
3939
3940 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3944 async fn metadata_requests_name_the_subscribed_topics() {
3945 use krabka_protocol::owned::{
3946 find_coordinator_response::FindCoordinatorResponse, metadata_request::MetadataRequest,
3947 };
3948
3949 let named = |topics: &[&str], allow| {
3950 krabka_client_core::topics_request(topics.iter().map(|&t| t.to_owned()), allow)
3951 };
3952 for (name, subscribe, allow_auto_create_topics, expected) in [
3953 (
3954 "subscribe a and b",
3955 vec!["a", "b"],
3956 true,
3957 named(&["a", "b"], true),
3958 ),
3959 ("auto creation off", vec!["a"], false, named(&["a"], false)),
3960 ] {
3961 let requests = Arc::new(StdMutex::new(Vec::new()));
3962 let handler_requests = Arc::clone(&requests);
3963 let mock = MockBroker::start(move |api_key, version, _corr_id, mut body| {
3964 match api_key {
3965 api_versions_request::API_KEY => Some(encoded_api_versions()),
3966 find_coordinator_request::API_KEY => Some(encode(
3967 &FindCoordinatorResponse {
3968 node_id: 1,
3969 host: "127.0.0.1".into(),
3970 port: 0,
3971 ..Default::default()
3972 },
3973 version,
3974 )),
3975 metadata_request::API_KEY => {
3976 let client_id_len = body.get_i16();
3977 body.advance(usize::try_from(client_id_len.max(0)).expect("length"));
3978 let request =
3979 MetadataRequest::decode(&mut body, version).expect("decode Metadata");
3980 handler_requests
3981 .lock()
3982 .expect("requests lock")
3983 .push(request);
3984 Some(encode(&MetadataResponse::default(), version))
3985 }
3986 _ => None,
3988 }
3989 })
3990 .await
3991 .unwrap();
3992 let mut config =
3993 start_config(mock.addr.to_string(), Assignor::Range, false, minutes(1));
3994 config.subscribe = subscribe.into_iter().map(str::to_owned).collect();
3995 config.allow_auto_create_topics = allow_auto_create_topics;
3996 let start = tokio::spawn(Consumer::start_once(config));
3997 let first = test_support::FirstRecorded::builder()
3998 .requests(Arc::clone(&requests))
3999 .timeout(Duration::from_secs(10))
4000 .poll_interval(Duration::from_millis(5))
4001 .build()
4002 .wait()
4003 .await
4004 .expect("the consumer sends Metadata");
4005 start.abort();
4006 mock.stop();
4007 assert2::check!(first == expected, "{name}");
4008 }
4009 }
4010
4011 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4016 async fn the_coordinator_task_of_a_leader_build_watches_the_group_topics() {
4017 use krabka_protocol::owned::{
4018 find_coordinator_response::FindCoordinatorResponse,
4019 heartbeat_response::HeartbeatResponse,
4020 };
4021
4022 let requests = Arc::new(StdMutex::new(Vec::new()));
4023 let handler_requests = Arc::clone(&requests);
4024 let mock = MockBroker::start(move |api_key, version, _corr_id, mut body| match api_key {
4025 api_versions_request::API_KEY => Some(encoded_api_versions()),
4026 find_coordinator_request::API_KEY => {
4027 Some(encode(&FindCoordinatorResponse::default(), version))
4028 }
4029 heartbeat_request::API_KEY => Some(encode(&HeartbeatResponse::default(), version)),
4030 metadata_request::API_KEY => {
4031 let names = test_support::metadata_topic_names(&mut body, version);
4032 handler_requests.lock().expect("requests lock").push(names);
4033 Some(encode(&MetadataResponse::default(), version))
4034 }
4035 _ => None,
4036 })
4037 .await
4038 .unwrap();
4039 let mut config = start_config(mock.addr.to_string(), Assignor::Range, false, minutes(1));
4040 config.heartbeat_interval = millis(50);
4041 config.subscription_metadata_refresh_interval = millis(50);
4042 let consumer = start_test_consumer(
4043 TestConsumerStart::builder()
4044 .bootstrap(mock.addr.to_string())
4045 .config(config)
4046 .startup(
4047 StartupState::builder()
4048 .generation_id(1)
4049 .group_topics(vec![TOPIC.to_owned(), "payments".to_owned()])
4050 .assigned_partitions(vec![partition(0)])
4051 .next_offsets(maplit::hashmap! {partition(0) => 12})
4052 .topic_partitions(maplit::hashmap! {TOPIC.to_owned() => 1})
4053 .build(),
4054 )
4055 .build(),
4056 )
4057 .await;
4058 let first = test_support::FirstRecorded::builder()
4059 .requests(Arc::clone(&requests))
4060 .timeout(Duration::from_secs(5))
4061 .poll_interval(Duration::from_millis(10))
4062 .build()
4063 .wait()
4064 .await;
4065 drop(consumer);
4066 mock.stop();
4067 assert2::assert!(first == Ok(vec![TOPIC.to_owned(), "payments".to_owned()]));
4068 }
4069
4070 pub(super) fn start_config(
4071 bootstrap: String,
4072 assignor: Assignor,
4073 auto_commit: bool,
4074 rebalance_timeout: Time,
4075 ) -> StartConfig {
4076 StartConfig {
4077 bootstrap,
4078 client_id: "auto-commit-test".into(),
4079 group_id: GROUP.into(),
4080 session_timeout: Time::from_std(SESSION_TIMEOUT),
4081 max_poll_interval: rebalance_timeout,
4082 max_poll_records: DEFAULT_CONSUMER_MAX_POLL_RECORDS,
4083 heartbeat_interval: secs(3),
4084 subscription_metadata_refresh_interval: minutes(60),
4085 subscribe: vec![TOPIC.into()],
4086 subscribe_pattern: None,
4087 subscribe_regex: None,
4088 exclude_internal_topics: true,
4089 group_protocol: GroupProtocol::Classic,
4090 server_assignor: None,
4091 group_instance_id: None,
4092 auto_offset_reset: AutoOffsetReset::Earliest,
4093 isolation_level: IsolationLevel::ReadUncommitted,
4094 assignors: vec![assignor],
4095 rebalance_protocol: assignor::rebalance_protocol_of(&[assignor])
4096 .expect("assignor protocol"),
4097 fetch_min: krabka_client_core::DEFAULT_FETCH_MIN,
4098 fetch_max: poll::DEFAULT_FETCH_MAX,
4099 fetch_partition_max: poll::DEFAULT_FETCH_PARTITION_MAX,
4100 fetch_max_wait: DEFAULT_CONSUMER_FETCH_MAX_WAIT,
4101 metadata_max_age: DEFAULT_CONSUMER_METADATA_MAX_AGE,
4102 default_api_timeout: DEFAULT_CONSUMER_DEFAULT_API_TIMEOUT,
4103 request_timeout: secs(30),
4104 dns_timeout: krabka_client_core::DEFAULT_CLIENT_DNS_TIMEOUT,
4105 send_buffer: Some(krabka_client_core::DEFAULT_SEND_BUFFER),
4106 receive_buffer: Some(krabka_client_core::DEFAULT_RECEIVE_BUFFER),
4107 dispatch_queue_capacity: ConnectionDispatchQueueCapacity::new(
4108 krabka_client_core::DEFAULT_CONNECTION_DISPATCH_QUEUE_CAPACITY,
4109 )
4110 .expect("dispatch queue capacity"),
4111 frame_max: ClientFrameMax::try_from(krabka_client_core::DEFAULT_CLIENT_FRAME_MAX)
4112 .expect("frame max"),
4113 metadata_recovery_strategy: krabka_client_core::MetadataRecoveryStrategy::default(),
4114 metadata_recovery_rebootstrap_trigger:
4115 krabka_client_core::DEFAULT_METADATA_RECOVERY_REBOOTSTRAP_TRIGGER,
4116 leave_group_timeout: secs(5),
4117 client_rack: None,
4118 security: None,
4119 client_dns_lookup: krabka_client_core::ClientDnsLookup::default(),
4120 reconnect_backoff: krabka_client_core::DEFAULT_RECONNECT_BACKOFF,
4121 reconnect_backoff_max: krabka_client_core::DEFAULT_RECONNECT_BACKOFF_MAX,
4122 connections_max_idle: krabka_client_core::DEFAULT_CONNECTIONS_MAX_IDLE,
4123 socket_connection_setup_timeout:
4124 krabka_client_core::DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT,
4125 socket_connection_setup_timeout_max:
4126 krabka_client_core::DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX,
4127 has_rebalance_listener: false,
4128 retry_policy: ConsumerRetryPolicy::default(),
4129 auto_commit_interval: auto_commit.then_some(AUTO_COMMIT_INTERVAL),
4130 allow_auto_create_topics: true,
4131 telemetry: None,
4132 }
4133 }
4134
4135 #[derive(Clone, Copy, Debug)]
4137 enum Step {
4138 Poll,
4140 AdvanceInterval,
4142 ReceiveRecords,
4145 Rebalance { syncs: usize, within: Duration },
4149 CommitSync,
4151 CommitSyncDuringRebalance {
4155 reply: CommitReply,
4156 syncs: usize,
4157 within: Duration,
4158 },
4159 ReplyToNextCommit(CommitReply),
4161 HoldCommitLock,
4163 DropHeartbeats,
4166 Close,
4168 }
4169
4170 pub(super) async fn wait_for_sync_groups(
4175 coordinator: &MockCoordinator,
4176 polls: &coordinator::PollSignal,
4177 syncs: usize,
4178 within: Duration,
4179 ) {
4180 let waited = timeout(within, async {
4181 while coordinator.sync_groups() < syncs {
4182 coordinator::note_poll(polls);
4183 sleep(Duration::from_millis(100)).await;
4184 }
4185 })
4186 .await;
4187 if waited.is_err() {
4188 coordinator.record(GroupRequest::SyncGroupLate);
4189 }
4190 sleep(Duration::from_secs(1)).await;
4192 }
4193
4194 async fn run(
4198 auto_commit: bool,
4199 assignor: Assignor,
4200 assignments: Vec<Vec<(String, i32)>>,
4201 steps: &[Step],
4202 ) -> Vec<GroupRequest> {
4203 run_with_rebalance_timeout(auto_commit, assignor, assignments, minutes(1), steps).await
4204 }
4205
4206 async fn run_with_rebalance_timeout(
4208 auto_commit: bool,
4209 assignor: Assignor,
4210 assignments: Vec<Vec<(String, i32)>>,
4211 rebalance_timeout: Time,
4212 steps: &[Step],
4213 ) -> Vec<GroupRequest> {
4214 let coordinator = MockCoordinator::new(assignor, assignments);
4215 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
4216 let config = start_config(
4217 mock.addr.to_string(),
4218 assignor,
4219 auto_commit,
4220 rebalance_timeout,
4221 );
4222 let client = Client::builder()
4223 .bootstrap(mock.addr.to_string())
4224 .build()
4225 .await
4226 .expect("client");
4227 let mut consumer = Some(
4228 spawn_consumer(
4229 config,
4230 client,
4231 Arc::new(AtomicI32::new(0)),
4232 MEMBER.into(),
4233 StartupState::builder()
4234 .generation_id(1)
4235 .assigned_partitions(vec![partition(0), partition(1)])
4236 .next_offsets(maplit::hashmap! {partition(0) => 12, partition(1) => 7})
4237 .positions(maplit::hashmap! {partition(0) => primed_position(3)})
4238 .topic_partitions(maplit::hashmap! {TOPIC.to_owned() => 2})
4239 .build(),
4240 )
4241 .await
4242 .expect("spawn consumer"),
4243 );
4244 let mut held_commit_lock = None;
4245
4246 for step in steps {
4247 match step {
4248 Step::Poll => {
4249 let consumer = consumer.as_ref().expect("open consumer");
4250 consumer.maybe_auto_commit_async().await;
4251 drop(consumer.commit_serialization.lock().await);
4252 }
4253 Step::AdvanceInterval => advance(AUTO_COMMIT_INTERVAL).await,
4254 Step::ReceiveRecords => {
4255 let consumer = consumer.as_ref().expect("open consumer");
4256 consumer.next_offsets.lock().await.insert(partition(0), 20);
4257 }
4258 Step::Rebalance { syncs, within } => {
4259 let consumer = consumer.as_ref().expect("open consumer");
4260 coordinator
4261 .heartbeat_error
4262 .store(REBALANCE_IN_PROGRESS, Ordering::SeqCst);
4263 wait_for_sync_groups(&coordinator, &consumer.poll_signal, *syncs, *within)
4264 .await;
4265 }
4266 Step::CommitSync => {
4267 let consumer = consumer.as_ref().expect("open consumer");
4268 consumer.commit_sync().await.expect("commit_sync");
4269 coordinator.record(GroupRequest::CommitSyncReturned);
4270 }
4271 Step::CommitSyncDuringRebalance {
4272 reply,
4273 syncs,
4274 within,
4275 } => {
4276 let consumer = consumer.as_ref().expect("open consumer");
4277 coordinator
4278 .commit_replies
4279 .lock()
4280 .expect("commit replies lock")
4281 .push_back(*reply);
4282 coordinator
4283 .rebalance_on_commit
4284 .store(true, Ordering::SeqCst);
4285 let commit = async {
4286 let _ = consumer.commit_sync().await;
4289 coordinator.record(GroupRequest::CommitSyncReturned);
4290 };
4291 tokio::join!(
4292 commit,
4293 wait_for_sync_groups(&coordinator, &consumer.poll_signal, *syncs, *within)
4294 );
4295 }
4296 Step::ReplyToNextCommit(reply) => {
4297 coordinator
4298 .commit_replies
4299 .lock()
4300 .expect("commit replies lock")
4301 .push_back(*reply);
4302 }
4303 Step::DropHeartbeats => {
4304 coordinator.drop_heartbeats.store(true, Ordering::SeqCst);
4305 }
4306 Step::HoldCommitLock => {
4307 let consumer = consumer.as_ref().expect("open consumer");
4308 held_commit_lock = Some(
4309 Arc::clone(&consumer.commit_serialization)
4310 .lock_owned()
4311 .await,
4312 );
4313 }
4314 Step::Close => {
4315 let close = consumer.take().expect("open consumer").close();
4316 match timeout(Duration::from_mins(2), close).await {
4317 Ok(result) => result.expect("close"),
4318 Err(_) => coordinator.record(GroupRequest::CloseTimedOut),
4319 }
4320 }
4321 }
4322 }
4323
4324 let requests = coordinator.requests();
4325 drop(held_commit_lock);
4326 drop(consumer);
4327 mock.stop();
4328 requests
4329 }
4330
4331 #[derive(bon::Builder)]
4333 struct AutoCommitCase {
4334 name: &'static str,
4335 #[builder(default = true)]
4336 auto_commit: bool,
4337 #[builder(default = Assignor::Range)]
4338 assignor: Assignor,
4339 #[builder(default)]
4340 assignments: Vec<Vec<(String, i32)>>,
4341 steps: Vec<Step>,
4342 expected: Vec<GroupRequest>,
4343 }
4344
4345 fn rebalance_steps(syncs: usize) -> Vec<Step> {
4346 use Step::{Poll, Rebalance, ReceiveRecords};
4347
4348 vec![
4349 Poll,
4350 ReceiveRecords,
4351 Rebalance {
4352 syncs,
4353 within: REBALANCE_WAIT,
4354 },
4355 ]
4356 }
4357
4358 #[derive(bon::Builder)]
4359 struct AutoCommitTimeoutCase {
4360 name: &'static str,
4361 #[builder(default = minutes(1))]
4362 rebalance_timeout: Time,
4363 steps: Vec<Step>,
4364 expected: Vec<GroupRequest>,
4365 }
4366
4367 trait NamedGroupRequestCase {
4368 fn name(&self) -> &'static str;
4369 fn expected(&self) -> &[GroupRequest];
4370 }
4371
4372 impl NamedGroupRequestCase for AutoCommitCase {
4373 fn name(&self) -> &'static str {
4374 self.name
4375 }
4376
4377 fn expected(&self) -> &[GroupRequest] {
4378 &self.expected
4379 }
4380 }
4381
4382 impl NamedGroupRequestCase for AutoCommitTimeoutCase {
4383 fn name(&self) -> &'static str {
4384 self.name
4385 }
4386
4387 fn expected(&self) -> &[GroupRequest] {
4388 &self.expected
4389 }
4390 }
4391
4392 #[derive(Debug)]
4393 struct GroupRequestObservation {
4394 name: &'static str,
4395 actual: Vec<GroupRequest>,
4396 expected: Vec<GroupRequest>,
4397 }
4398
4399 async fn assert_group_request_cases<C, F, Fut>(cases: Vec<C>, mut run: F)
4400 where
4401 C: NamedGroupRequestCase,
4402 F: FnMut(C) -> Fut,
4403 Fut: Future<Output = Vec<GroupRequest>>,
4404 {
4405 let mut observations = Vec::with_capacity(cases.len());
4406 for case in cases {
4407 let name = case.name();
4408 let expected = case.expected().to_vec();
4409 observations.push(GroupRequestObservation {
4410 name,
4411 actual: run(case).await,
4412 expected,
4413 });
4414 }
4415 for observation in &observations {
4416 assert2::check!(
4417 observation.actual == observation.expected,
4418 "{}",
4419 observation.name
4420 );
4421 }
4422 }
4423
4424 fn rebalance_step(within: Duration) -> Step {
4425 Step::Rebalance { syncs: 1, within }
4426 }
4427
4428 fn commit_sync_then_rebalance_requests(received: &[(i32, i64, i32)]) -> Vec<GroupRequest> {
4429 use GroupRequest::{CommitSyncReturned, JoinGroup, SyncGroup};
4430
4431 vec![
4432 commit(1, received),
4433 CommitSyncReturned,
4434 commit(1, received),
4435 JoinGroup,
4436 SyncGroup,
4437 ]
4438 }
4439
4440 #[tokio::test(start_paused = true)]
4445 async fn auto_commit_sends_offset_commit_where_kafka_does() {
4446 use GroupRequest::{JoinGroup, LeaveGroup, SyncGroup};
4447 use Step::{AdvanceInterval, Close, Poll, ReceiveRecords};
4448
4449 let both = [(0, 12, 3), (1, 7, -1)];
4450 let cases = vec![
4451 AutoCommitCase::builder()
4452 .name("on: a poll after the interval commits the positions")
4453 .steps(vec![Poll, AdvanceInterval, Poll])
4454 .expected(vec![commit(1, &both)])
4455 .build(),
4456 AutoCommitCase::builder()
4457 .name("on: a poll before the interval commits nothing")
4458 .steps(vec![Poll, Poll])
4459 .expected(vec![])
4460 .build(),
4461 AutoCommitCase::builder()
4462 .name("off: a poll after the interval commits nothing")
4463 .auto_commit(false)
4464 .steps(vec![Poll, AdvanceInterval, Poll])
4465 .expected(vec![])
4466 .build(),
4467 AutoCommitCase::builder()
4468 .name("on, eager: a rebalance commits the positions of the last poll before JoinGroup")
4469 .assignments(vec![vec![partition(0), partition(1)]])
4470 .steps(rebalance_steps(1))
4471 .expected(vec![commit(1, &both), JoinGroup, SyncGroup])
4472 .build(),
4473 AutoCommitCase::builder()
4474 .name("off, eager: a rebalance commits nothing")
4475 .auto_commit(false)
4476 .assignments(vec![vec![partition(0), partition(1)]])
4477 .steps(rebalance_steps(1))
4478 .expected(vec![JoinGroup, SyncGroup])
4479 .build(),
4480 AutoCommitCase::builder()
4481 .name("on, cooperative: a revoke of partition 1 commits before each JoinGroup")
4482 .assignor(Assignor::CooperativeSticky)
4483 .assignments(vec![vec![partition(0)]])
4484 .steps(rebalance_steps(2))
4485 .expected(vec![
4486 commit(1, &both),
4487 JoinGroup,
4488 SyncGroup,
4489 commit(2, &[(0, 12, 3)]),
4490 JoinGroup,
4491 SyncGroup,
4492 ])
4493 .build(),
4494 AutoCommitCase::builder()
4495 .name("off, cooperative: a revoke of partition 1 commits nothing")
4496 .auto_commit(false)
4497 .assignor(Assignor::CooperativeSticky)
4498 .assignments(vec![vec![partition(0)]])
4499 .steps(rebalance_steps(2))
4500 .expected(vec![JoinGroup, SyncGroup, JoinGroup, SyncGroup])
4501 .build(),
4502 AutoCommitCase::builder()
4503 .name("on: close commits the current positions, then leaves the group")
4504 .steps(vec![Poll, ReceiveRecords, Close])
4505 .expected(vec![commit(1, &[(0, 20, 3), (1, 7, -1)]), LeaveGroup])
4506 .build(),
4507 AutoCommitCase::builder()
4508 .name("off: close only leaves the group")
4509 .auto_commit(false)
4510 .assignor(Assignor::CooperativeSticky)
4511 .steps(vec![Poll, ReceiveRecords, Close])
4512 .expected(vec![LeaveGroup])
4513 .build(),
4514 ];
4515 assert_group_request_cases(cases, |case| async move {
4516 run(
4517 case.auto_commit,
4518 case.assignor,
4519 case.assignments,
4520 &case.steps,
4521 )
4522 .await
4523 })
4524 .await;
4525 }
4526
4527 #[tokio::test(start_paused = true)]
4533 async fn auto_commit_keeps_the_commit_order_within_the_group_timeouts() {
4534 use CommitReply::{Drop, Error};
4535 use GroupRequest::{CommitSyncReturned, JoinGroup, LeaveGroup, SyncGroup};
4536 use Step::{
4537 Close, CommitSync, CommitSyncDuringRebalance, DropHeartbeats, HoldCommitLock, Poll,
4538 ReceiveRecords, ReplyToNextCommit,
4539 };
4540
4541 let polled = [(0, 12, 3), (1, 7, -1)];
4542 let received = [(0, 20, 3), (1, 7, -1)];
4543 let both_partitions = vec![vec![partition(0), partition(1)]];
4544 let cases = vec![
4545 AutoCommitTimeoutCase::builder()
4546 .name("commit_sync, then a rebalance: the commit before JoinGroup does not go back")
4547 .steps(vec![
4548 Poll,
4549 ReceiveRecords,
4550 CommitSync,
4551 rebalance_step(REBALANCE_WAIT),
4552 ])
4553 .expected(commit_sync_then_rebalance_requests(&received))
4554 .build(),
4555 AutoCommitTimeoutCase::builder()
4556 .name(
4557 "a rebalance while commit_sync waits for its response: the commit before \
4558 JoinGroup comes after it",
4559 )
4560 .steps(vec![
4561 Poll,
4562 ReceiveRecords,
4563 CommitSyncDuringRebalance {
4564 reply: Drop,
4565 syncs: 1,
4566 within: Duration::from_mins(1),
4567 },
4568 ])
4569 .expected(vec![
4570 commit(1, &received),
4575 commit(1, &received),
4576 CommitSyncReturned,
4577 commit(1, &received),
4578 JoinGroup,
4579 SyncGroup,
4580 ])
4581 .build(),
4582 AutoCommitTimeoutCase::builder()
4583 .name(
4584 "a rebalance while commit_sync waits for its response, with a \
4585 REBALANCE_IN_PROGRESS reply: commit_sync fails immediately instead of \
4586 waiting for the rejoin, so the commit before JoinGroup comes after it",
4587 )
4588 .steps(vec![
4589 Poll,
4590 ReceiveRecords,
4591 CommitSyncDuringRebalance {
4592 reply: Error(REBALANCE_IN_PROGRESS),
4593 syncs: 1,
4594 within: REBALANCE_WAIT,
4595 },
4596 ])
4597 .expected(commit_sync_then_rebalance_requests(&received))
4598 .build(),
4599 AutoCommitTimeoutCase::builder()
4600 .name(
4601 "another commit holds the commit lock: JoinGroup at the rebalance timeout, \
4602 without a commit",
4603 )
4604 .rebalance_timeout(secs(10))
4605 .steps(vec![Poll, HoldCommitLock, rebalance_step(REBALANCE_WAIT)])
4606 .expected(vec![JoinGroup, SyncGroup])
4607 .build(),
4608 AutoCommitTimeoutCase::builder()
4609 .name(
4610 "another commit holds the commit lock past the session timeout: heartbeats keep \
4611 the member in the group",
4612 )
4613 .steps(vec![
4614 Poll,
4615 HoldCommitLock,
4616 rebalance_step(Duration::from_secs(90)),
4617 ])
4618 .expected(vec![JoinGroup, SyncGroup])
4619 .build(),
4620 AutoCommitTimeoutCase::builder()
4621 .name(
4622 "the coordinator does not answer a heartbeat during the commit before JoinGroup: \
4623 JoinGroup at the rebalance timeout",
4624 )
4625 .rebalance_timeout(secs(10))
4626 .steps(vec![
4627 Poll,
4628 HoldCommitLock,
4629 DropHeartbeats,
4630 rebalance_step(Duration::from_secs(20)),
4631 ])
4632 .expected(vec![JoinGroup, SyncGroup])
4633 .build(),
4634 AutoCommitTimeoutCase::builder()
4635 .name(
4636 "the coordinator does not answer the commit before JoinGroup: JoinGroup at the \
4637 rebalance timeout",
4638 )
4639 .rebalance_timeout(secs(10))
4640 .steps(vec![Poll, ReplyToNextCommit(Drop), rebalance_step(REBALANCE_WAIT)])
4641 .expected(vec![commit(1, &polled), JoinGroup, SyncGroup])
4642 .build(),
4643 AutoCommitTimeoutCase::builder()
4644 .name(
4645 "another commit holds the commit lock: close leaves the group after the close \
4646 timeout",
4647 )
4648 .steps(vec![Poll, ReceiveRecords, HoldCommitLock, Close])
4649 .expected(vec![LeaveGroup])
4650 .build(),
4651 ];
4652 assert_group_request_cases(cases, |case| {
4653 let assignments = both_partitions.clone();
4654 async move {
4655 run_with_rebalance_timeout(
4656 true,
4657 Assignor::Range,
4658 assignments,
4659 case.rebalance_timeout,
4660 &case.steps,
4661 )
4662 .await
4663 }
4664 })
4665 .await;
4666 }
4667
4668 #[test]
4669 fn auto_commit_interval_follows_kafka_config_bounds() {
4670 let mut actual = Vec::new();
4671 let mut wanted = Vec::new();
4672 for (name, enable, interval, expected) in [
4673 ("off", false, Time::from_millis(-1), Ok(None)),
4674 ("default", true, secs(5), Ok(Some(Duration::from_secs(5)))),
4675 ("zero", true, Time::from_millis(0), Ok(Some(Duration::ZERO))),
4676 (
4677 "i32::MAX milliseconds",
4678 true,
4679 Time::from_millis(i64::from(i32::MAX)),
4680 Ok(Some(Duration::from_millis(u64::from(
4681 i32::MAX.unsigned_abs(),
4682 )))),
4683 ),
4684 ("negative", true, Time::from_millis(-1), Err(())),
4685 (
4686 "above i32::MAX milliseconds",
4687 true,
4688 Time::from_millis(i64::from(i32::MAX) + 1),
4689 Err(()),
4690 ),
4691 (
4692 "half a millisecond",
4693 true,
4694 Time::from_secs_f64(0.0005),
4695 Err(()),
4696 ),
4697 (
4698 "i32::MAX milliseconds and a fraction",
4699 true,
4700 Time::from_secs_f64((f64::from(i32::MAX) + 0.25) / 1e3),
4701 Err(()),
4702 ),
4703 ("not a number", true, Time::from_secs_f64(f64::NAN), Err(())),
4704 (
4705 "infinite",
4706 true,
4707 Time::from_secs_f64(f64::INFINITY),
4708 Err(()),
4709 ),
4710 ] {
4711 let result = validated_auto_commit_interval(enable, interval).map_err(|_| ());
4712 actual.push((name, result));
4713 wanted.push((name, expected));
4714 }
4715 assert2::assert!(actual == wanted);
4716 }
4717}
4718
4719#[cfg(test)]
4720mod group_rejoin_tests {
4721 use super::{
4722 auto_commit_tests::{
4723 GroupRequest, MockCoordinator, partition, start_config, wait_for_sync_groups,
4724 },
4725 *,
4726 };
4727
4728 const FENCED_INSTANCE_ID: i16 = 82;
4730
4731 #[derive(Debug, PartialEq)]
4733 struct FenceObservation {
4734 first_poll: Result<usize, String>,
4735 commit_after_fence: Result<(), String>,
4736 requests_before_next_poll: Vec<GroupRequest>,
4737 next_poll: Result<usize, String>,
4738 requests_after_next_poll: Vec<GroupRequest>,
4739 assignment: Vec<(String, i32)>,
4740 generation: i32,
4741 }
4742
4743 #[tokio::test(start_paused = true)]
4749 async fn poll_after_a_fenced_instance_id_joins_the_group_again() {
4750 let coordinator = MockCoordinator::range_partition(1);
4751 coordinator.set_heartbeat_error(FENCED_INSTANCE_ID);
4752 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
4753 let mut config = start_config(mock.addr.to_string(), Assignor::Range, false, minutes(1));
4754 config.group_instance_id = Some("instance-a".into());
4755 let mut consumer = start_test_consumer(
4756 TestConsumerStart::builder()
4757 .bootstrap(mock.addr.to_string())
4758 .config(config)
4759 .startup(super::auto_commit_tests::two_partition_startup())
4760 .build(),
4761 )
4762 .await;
4763 timeout(Duration::from_secs(30), async {
4765 while !coordinator::poll_error_pending(&consumer.poll_error) {
4766 sleep(Duration::from_millis(100)).await;
4767 }
4768 })
4769 .await
4770 .expect("the coordinator fences the member");
4771
4772 let first_poll = consumer
4773 .poll(millis(100))
4774 .await
4775 .map(|records| records.len())
4776 .map_err(|error| error.to_string());
4777 sleep(Duration::from_secs(20)).await;
4779 let commit_after_fence = consumer
4780 .commit_sync()
4781 .await
4782 .map_err(|error| error.to_string());
4783 let requests_before_next_poll = coordinator.requests();
4784 let next_poll = consumer
4785 .poll(millis(100))
4786 .await
4787 .map(|records| records.len())
4788 .map_err(|error| error.to_string());
4789 wait_for_sync_groups(
4790 &coordinator,
4791 &consumer.poll_signal,
4792 1,
4793 Duration::from_secs(30),
4794 )
4795 .await;
4796 let observation = FenceObservation {
4797 first_poll,
4798 commit_after_fence,
4799 requests_before_next_poll,
4800 next_poll,
4801 requests_after_next_poll: coordinator
4802 .requests()
4803 .into_iter()
4804 .filter(|request| *request != GroupRequest::SessionExpired)
4805 .collect(),
4806 assignment: consumer.assignment().await,
4807 generation: consumer.generation_id(),
4808 };
4809 consumer.close().await.expect("close");
4810 mock.stop();
4811
4812 assert2::assert!(
4813 observation
4814 == FenceObservation {
4815 first_poll: Err(
4816 ConsumerError::FencedInstanceId("instance-a".into()).to_string()
4817 ),
4818 commit_after_fence: Err(ConsumerError::CommitFailed.to_string()),
4819 requests_before_next_poll: Vec::new(),
4820 next_poll: Ok(0),
4821 requests_after_next_poll: vec![
4822 GroupRequest::JoinGroup,
4823 GroupRequest::SyncGroup
4824 ],
4825 assignment: vec![partition(1)],
4826 generation: 2,
4827 }
4828 );
4829 }
4830}
4831
4832#[cfg(test)]
4833mod poll_interval_tests {
4834 use krabka_protocol::owned::leave_group_request::LeaveGroupRequest;
4835
4836 use super::{
4837 auto_commit_tests::{MEMBER, MockCoordinator, TOPIC, start_config},
4838 *,
4839 };
4840
4841 #[derive(Clone, Copy, Debug)]
4847 enum Polls {
4848 EachTenthOfASecond,
4850 Late,
4852 }
4853
4854 #[derive(Debug, PartialEq)]
4856 struct PollTimeoutObservation {
4857 leaves: Vec<LeaveGroupRequest>,
4858 heartbeats_after_timeout: bool,
4859 commit: Result<(), String>,
4860 member_id_before_next_poll: String,
4862 joins_after_next_poll: Vec<(String, Option<String>)>,
4865 member_id_after_next_poll: String,
4867 }
4868
4869 async fn run_poll_timeout_case(
4870 group_instance_id: Option<&str>,
4871 polls: Polls,
4872 ) -> PollTimeoutObservation {
4873 let coordinator = MockCoordinator::range_partition(0);
4874 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
4875 let mut config = start_config(mock.addr.to_string(), Assignor::Range, false, millis(500));
4876 config.group_instance_id = group_instance_id.map(str::to_owned);
4877 config.heartbeat_interval = millis(100);
4878 let mut consumer = super::auto_commit_tests::start_single_partition_consumer(config).await;
4879 let spawned_at = *coordinator
4881 .last_heartbeat
4882 .lock()
4883 .expect("last heartbeat lock");
4884 while *coordinator
4885 .last_heartbeat
4886 .lock()
4887 .expect("last heartbeat lock")
4888 == spawned_at
4889 {
4890 sleep(Duration::from_millis(10)).await;
4891 }
4892 coordinator::note_poll(&consumer.poll_signal);
4893
4894 let poller = matches!(polls, Polls::EachTenthOfASecond).then(|| {
4895 let poll_signal = consumer.poll_signal.clone();
4896 tokio::spawn(async move {
4897 loop {
4898 sleep(Duration::from_millis(100)).await;
4899 coordinator::note_poll(&poll_signal);
4900 }
4901 })
4902 });
4903 sleep(Duration::from_millis(700)).await;
4904 let heartbeat_at_timeout = *coordinator
4905 .last_heartbeat
4906 .lock()
4907 .expect("last heartbeat lock");
4908 sleep(Duration::from_millis(400)).await;
4910 let heartbeats_after_timeout = *coordinator
4911 .last_heartbeat
4912 .lock()
4913 .expect("last heartbeat lock")
4914 != heartbeat_at_timeout;
4915 let commit = consumer
4916 .commit_sync()
4917 .await
4918 .map_err(|error| error.to_string());
4919 let leaves = coordinator.leaves.lock().expect("leaves lock").clone();
4920 let member_id_before_next_poll = consumer.member_id();
4921 let joins_before = coordinator.joins.lock().expect("joins lock").len();
4922 consumer.poll(millis(0)).await.expect("next poll");
4923 let _ = timeout(Duration::from_secs(5), async {
4925 while consumer.member_id().is_empty() {
4926 coordinator::note_poll(&consumer.poll_signal);
4927 sleep(Duration::from_millis(10)).await;
4928 }
4929 })
4930 .await;
4931 let joins_after_next_poll = coordinator.joins.lock().expect("joins lock")[joins_before..]
4932 .iter()
4933 .map(|join| (join.member_id.clone(), join.group_instance_id.clone()))
4934 .collect();
4935 let member_id_after_next_poll = consumer.member_id();
4936 if let Some(poller) = poller {
4937 poller.abort();
4938 }
4939 coordinator.set_heartbeat_error(0);
4940 drop(consumer);
4941 mock.stop();
4942 PollTimeoutObservation {
4943 leaves,
4944 heartbeats_after_timeout,
4945 commit,
4946 member_id_before_next_poll,
4947 joins_after_next_poll,
4948 member_id_after_next_poll,
4949 }
4950 }
4951
4952 #[tokio::test]
4960 async fn a_member_leaves_the_group_when_poll_does_not_come_within_max_poll_interval() {
4961 let commit_failed = Err(ConsumerError::CommitFailed.to_string());
4962 let leave =
4963 test_support::dynamic_member_leave_request("consumer poll timeout has expired.");
4964 let mut actual = Vec::new();
4965 let mut wanted = Vec::new();
4966 for (name, group_instance_id, polls, expected) in [
4967 (
4968 "dynamic member polls in time",
4969 None,
4970 Polls::EachTenthOfASecond,
4971 PollTimeoutObservation {
4972 leaves: Vec::new(),
4973 heartbeats_after_timeout: true,
4974 commit: Ok(()),
4975 member_id_before_next_poll: MEMBER.into(),
4976 joins_after_next_poll: Vec::new(),
4977 member_id_after_next_poll: MEMBER.into(),
4978 },
4979 ),
4980 (
4981 "dynamic member polls late",
4982 None,
4983 Polls::Late,
4984 PollTimeoutObservation {
4985 leaves: vec![leave],
4986 heartbeats_after_timeout: false,
4987 commit: commit_failed.clone(),
4988 member_id_before_next_poll: String::new(),
4989 joins_after_next_poll: vec![(String::new(), None)],
4990 member_id_after_next_poll: MEMBER.into(),
4991 },
4992 ),
4993 (
4994 "static member polls late",
4995 Some("instance-a"),
4996 Polls::Late,
4997 PollTimeoutObservation {
4998 leaves: Vec::new(),
4999 heartbeats_after_timeout: false,
5000 commit: commit_failed,
5001 member_id_before_next_poll: String::new(),
5002 joins_after_next_poll: vec![(String::new(), Some("instance-a".into()))],
5003 member_id_after_next_poll: MEMBER.into(),
5004 },
5005 ),
5006 ] {
5007 actual.push((name, run_poll_timeout_case(group_instance_id, polls).await));
5008 wanted.push((name, expected));
5009 }
5010 assert2::assert!(actual == wanted);
5011 }
5012
5013 #[tokio::test]
5017 async fn a_poll_during_the_poll_timeout_leave_fetches_nothing_and_starts_the_join() {
5018 let coordinator = MockCoordinator::range_partition(0);
5019 coordinator.drop_leaves.store(true, Ordering::SeqCst);
5020 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5021 let mut config = start_config(mock.addr.to_string(), Assignor::Range, false, millis(300));
5022 config.heartbeat_interval = millis(50);
5023 config.leave_group_timeout = secs(1);
5024 let mut consumer = super::auto_commit_tests::start_single_partition_consumer(config).await;
5025 timeout(Duration::from_secs(10), async {
5026 while coordinator.leaves.lock().expect("leaves lock").is_empty() {
5027 sleep(Duration::from_millis(10)).await;
5028 }
5029 })
5030 .await
5031 .expect("the member sends LeaveGroup");
5032
5033 let records = consumer.poll(millis(100)).await.expect("poll");
5034 let joined = timeout(Duration::from_secs(5), async {
5035 while coordinator.joins.lock().expect("joins lock").is_empty() {
5036 sleep(Duration::from_millis(10)).await;
5037 }
5038 })
5039 .await
5040 .is_ok();
5041 let observation = (
5042 records.len(),
5043 joined,
5044 coordinator.leaves.lock().expect("leaves lock").len(),
5045 );
5046 drop(consumer);
5047 mock.stop();
5048 assert2::assert!(observation == (0, true, 1));
5049 }
5050
5051 #[tokio::test]
5054 async fn join_group_sends_max_poll_interval_as_the_rebalance_timeout() {
5055 let mut actual = Vec::new();
5056 let mut wanted = Vec::new();
5057 for (name, max_poll_interval, expected) in [
5058 ("default", None, 300_000),
5059 ("configured", Some(secs(10)), 10_000),
5060 ] {
5061 let coordinator = MockCoordinator::new(Assignor::Range, vec![Vec::new()]);
5062 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5063 let consumer = Consumer::builder()
5064 .bootstrap(mock.addr.to_string())
5065 .group_id("group-a")
5066 .subscribe(vec![TOPIC.to_owned()])
5067 .maybe_max_poll_interval(max_poll_interval)
5068 .build()
5069 .await
5070 .expect("build");
5071 let rebalance_timeouts: Vec<i32> = coordinator
5072 .joins
5073 .lock()
5074 .expect("joins lock")
5075 .iter()
5076 .map(|join| join.rebalance_timeout_ms)
5077 .collect();
5078 drop(consumer);
5079 mock.stop();
5080 actual.push((name, rebalance_timeouts));
5081 wanted.push((name, vec![expected, expected]));
5082 }
5083 assert2::assert!(actual == wanted);
5084 }
5085
5086 #[test]
5090 fn client_id_is_generated_as_kafka_does() {
5091 let first = consumer_client_id(None, "g", None);
5092 let sequence: u64 = first
5093 .strip_prefix("consumer-g-")
5094 .and_then(|n| n.parse().ok())
5095 .expect("generated sequence");
5096 let actual = [
5097 consumer_client_id(None, "g", None),
5098 consumer_client_id(Some(String::new()), "g", None),
5099 consumer_client_id(None, "g", Some("i-1")),
5100 consumer_client_id(Some(String::new()), "g", Some("i-1")),
5101 consumer_client_id(Some("app".into()), "g", Some("i-1")),
5102 ];
5103 let next = |step: u64| format!("consumer-g-{}", sequence + step);
5104 assert2::assert!(
5105 actual
5106 == [
5107 next(1),
5108 next(2),
5109 "consumer-g-i-1".to_owned(),
5110 "consumer-g-i-1".to_owned(),
5111 "app".to_owned(),
5112 ]
5113 );
5114 }
5115
5116 #[test]
5117 fn max_poll_settings_follow_kafka_config_bounds() {
5118 let interval = |value: Time| validated_max_poll_interval(value).map_err(|_| ());
5119 let records = |value: usize| validated_max_poll_records(value).map_err(|_| ());
5120 let actual = (
5121 [
5122 interval(Time::from_millis(0)),
5123 interval(Time::from_millis(1)),
5124 interval(DEFAULT_CONSUMER_MAX_POLL_INTERVAL),
5125 interval(Time::from_millis(i64::from(i32::MAX))),
5126 interval(Time::from_millis(i64::from(i32::MAX) + 1)),
5127 interval(Time::from_secs_f64(0.0015)),
5128 ],
5129 [
5130 records(0),
5131 records(1),
5132 records(DEFAULT_CONSUMER_MAX_POLL_RECORDS),
5133 records(usize::try_from(i32::MAX).expect("fits")),
5134 records(usize::try_from(i32::MAX).expect("fits") + 1),
5135 ],
5136 );
5137 let expected = (
5138 [
5139 Err(()),
5140 Ok(Time::from_millis(1)),
5141 Ok(minutes(5)),
5142 Ok(Time::from_millis(i64::from(i32::MAX))),
5143 Err(()),
5144 Err(()),
5145 ],
5146 [
5147 Err(()),
5148 Ok(1),
5149 Ok(500),
5150 Ok(usize::try_from(i32::MAX).expect("fits")),
5151 Err(()),
5152 ],
5153 );
5154 assert2::assert!(actual == expected);
5155 }
5156}
5157
5158#[cfg(test)]
5159mod group_membership_tests {
5160 use std::{
5161 sync::{
5162 Arc,
5163 atomic::{AtomicBool, Ordering},
5164 },
5165 time::Instant as StdInstant,
5166 };
5167
5168 use krabka_client_core::MockBroker;
5169 use tokio::{
5170 task::{spawn_blocking, yield_now},
5171 time,
5172 };
5173
5174 use super::{
5175 auto_commit_tests::{
5176 GroupRequest, MEMBER, MockCoordinator, TOPIC, partition, start_config,
5177 },
5178 *,
5179 };
5180
5181 async fn started_consumer(mock: &MockBroker, group_instance_id: Option<&str>) -> Consumer {
5182 started_consumer_with(mock, group_instance_id, &[Assignor::Range]).await
5183 }
5184
5185 async fn started_consumer_with(
5186 mock: &MockBroker,
5187 group_instance_id: Option<&str>,
5188 assignors: &[Assignor],
5189 ) -> Consumer {
5190 let mut config = start_config(mock.addr.to_string(), Assignor::Range, false, minutes(1));
5191 config.assignors = assignors.to_vec();
5192 config.rebalance_protocol =
5193 assignor::rebalance_protocol_of(assignors).expect("assignor protocol");
5194 config.group_instance_id = group_instance_id.map(str::to_owned);
5195 config.heartbeat_interval = millis(50);
5196 let client = Client::builder()
5197 .bootstrap(mock.addr.to_string())
5198 .build()
5199 .await
5200 .expect("client");
5201 spawn_consumer(
5202 config,
5203 client,
5204 Arc::new(AtomicI32::new(0)),
5205 MEMBER.into(),
5206 super::auto_commit_tests::single_partition_startup(1),
5207 )
5208 .await
5209 .expect("spawn consumer")
5210 }
5211
5212 struct StartedRangeConsumer {
5213 coordinator: Arc<MockCoordinator>,
5214 mock: MockBroker,
5215 consumer: Consumer,
5216 }
5217
5218 impl StartedRangeConsumer {
5219 async fn start(group_instance_id: Option<&str>) -> Self {
5220 let coordinator = MockCoordinator::range_partition(0);
5221 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5222 let consumer = started_consumer(&mock, group_instance_id).await;
5223 Self {
5224 coordinator,
5225 mock,
5226 consumer,
5227 }
5228 }
5229 }
5230
5231 #[tokio::test]
5236 async fn close_leaves_the_group_as_the_membership_operation_says() {
5237 let leave = test_support::dynamic_member_leave_request("the consumer is being closed");
5238 let mut actual = Vec::new();
5239 let mut wanted = Vec::new();
5240 for (name, group_instance_id, operation, expected) in [
5241 (
5242 "dynamic member, default",
5243 None,
5244 GroupMembershipOperation::Default,
5245 vec![leave.clone()],
5246 ),
5247 (
5248 "static member, default",
5249 Some("i-1"),
5250 GroupMembershipOperation::Default,
5251 Vec::new(),
5252 ),
5253 (
5254 "static member, leave group",
5255 Some("i-1"),
5256 GroupMembershipOperation::LeaveGroup,
5257 vec![leave.clone()],
5258 ),
5259 (
5260 "dynamic member, remain in group",
5261 None,
5262 GroupMembershipOperation::RemainInGroup,
5263 Vec::new(),
5264 ),
5265 ] {
5266 let fixture = StartedRangeConsumer::start(group_instance_id).await;
5267 fixture
5268 .consumer
5269 .close_with(CloseOptions::group_membership_operation(operation))
5270 .await
5271 .expect("close");
5272 fixture.mock.stop();
5273 actual.push((
5274 name,
5275 fixture
5276 .coordinator
5277 .leaves
5278 .lock()
5279 .expect("leaves lock")
5280 .clone(),
5281 ));
5282 wanted.push((name, expected));
5283 }
5284 assert2::assert!(actual == wanted);
5285 }
5286
5287 #[tokio::test]
5292 async fn a_join_after_the_member_was_reset_during_preparation_owns_nothing() {
5293 let coordinator =
5294 MockCoordinator::new(Assignor::CooperativeSticky, vec![vec![partition(0)]]);
5295 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5296 let mut config = start_config(
5297 mock.addr.to_string(),
5298 Assignor::CooperativeSticky,
5299 true,
5300 minutes(1),
5301 );
5302 config.heartbeat_interval = millis(50);
5303 let mut consumer = super::auto_commit_tests::start_single_partition_consumer(config).await;
5304 consumer.poll(millis(20)).await.expect("first poll");
5305 coordinator
5308 .commit_replies
5309 .lock()
5310 .expect("commit replies lock")
5311 .push_back(super::auto_commit_tests::CommitReply::Drop);
5312 coordinator.set_heartbeat_error(27);
5313 let deadline = Instant::now() + Duration::from_secs(5);
5314 let mut reset = false;
5315 while coordinator.joins.lock().expect("joins lock").is_empty() && Instant::now() < deadline
5316 {
5317 let _ = consumer.poll(millis(20)).await;
5318 let committing = coordinator
5319 .requests()
5320 .iter()
5321 .any(|request| matches!(request, GroupRequest::OffsetCommit(_)));
5322 if committing && !reset {
5323 coordinator.set_heartbeat_error(25);
5324 reset = true;
5325 }
5326 }
5327 let subscriptions: Vec<(Vec<(String, i32)>, i32)> = coordinator
5328 .joins
5329 .lock()
5330 .expect("joins lock")
5331 .iter()
5332 .flat_map(|join| join.protocols.iter())
5333 .map(|protocol| {
5334 let subscription = builder::decode_subscription(&protocol.metadata);
5335 (subscription.owned, subscription.generation_id)
5336 })
5337 .collect();
5338 drop(consumer);
5339 mock.stop();
5340 assert2::assert!(subscriptions.first() == Some(&(Vec::new(), -1)));
5341 }
5342
5343 #[tokio::test]
5347 async fn close_timeout_bounds_the_auto_commit() {
5348 let mut actual = Vec::new();
5349 let mut wanted = Vec::new();
5350 for (name, timeout, expected) in [("300 ms", millis(300), (Ok(()), true, 1))] {
5351 let coordinator = MockCoordinator::range_partition(0);
5352 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5353 let mut config = start_config(mock.addr.to_string(), Assignor::Range, true, minutes(1));
5354 config.heartbeat_interval = millis(50);
5355 let consumer = super::auto_commit_tests::start_single_partition_consumer(config).await;
5356 let held = Arc::clone(&consumer.commit_serialization)
5359 .lock_owned()
5360 .await;
5361 let started = Instant::now();
5362 let closed = time::timeout(
5363 Duration::from_secs(10),
5364 consumer.close_with(CloseOptions::timeout(timeout)),
5365 )
5366 .await
5367 .expect("close returns")
5368 .map_err(|error| error.to_string());
5369 let fast = started.elapsed() < Duration::from_secs(3);
5370 drop(held);
5371 mock.stop();
5372 let leaves = coordinator.leaves.lock().expect("leaves lock").len();
5373 actual.push((name, (closed, fast, leaves)));
5374 wanted.push((name, expected));
5375 }
5376 assert2::assert!(actual == wanted);
5377 }
5378
5379 #[tokio::test]
5382 async fn close_waits_for_a_pending_asynchronous_commit() {
5383 let coordinator = MockCoordinator::range_partition(0);
5384 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5385 let consumer = started_consumer(&mock, None).await;
5386 let held = Arc::clone(&consumer.commit_serialization)
5389 .lock_owned()
5390 .await;
5391 let release = tokio::spawn(async move {
5392 sleep(Duration::from_millis(200)).await;
5393 drop(held);
5394 });
5395 let called = Arc::new(AtomicBool::new(false));
5396 let called_in_callback = Arc::clone(&called);
5397 consumer
5398 .commit_async_with_callback(move |_, _| {
5399 called_in_callback.store(true, Ordering::SeqCst);
5400 })
5401 .expect("commit_async_with_callback");
5402 consumer.close().await.expect("close");
5403 let called_before_close_returned = called.load(Ordering::SeqCst);
5404 release.await.expect("release");
5405 mock.stop();
5406 assert2::assert!(called_before_close_returned);
5407 }
5408
5409 #[derive(Clone, Copy, Debug)]
5411 enum Change {
5412 Subscribe(&'static str),
5413 Unsubscribe,
5414 UnsubscribeThenSubscribe,
5415 PatternWithoutMatch,
5416 }
5417
5418 type SubscriptionOutcome = (
5422 Vec<(Vec<String>, Option<String>)>,
5423 Vec<Option<String>>,
5424 Vec<String>,
5425 );
5426
5427 #[tokio::test]
5432 async fn subscription_changes_follow_kafka() {
5433 let changed = |from: &str, to: &str| {
5434 Some(format!(
5435 "the subscription has changed from [{from}] to [{to}] since the last group join"
5436 ))
5437 };
5438 let mut actual = Vec::new();
5439 let mut wanted = Vec::new();
5440 for (name, change, expected) in [
5441 (
5442 "subscribe to another topic",
5443 Change::Subscribe("payments"),
5444 (
5445 vec![(vec!["payments".to_owned()], changed("orders", "payments"))],
5446 vec![],
5447 vec![],
5448 ),
5449 ),
5450 (
5451 "subscribe to the same topic",
5452 Change::Subscribe("orders"),
5453 (vec![], vec![], vec![]),
5454 ),
5455 (
5456 "unsubscribe",
5457 Change::Unsubscribe,
5458 (
5459 vec![],
5460 vec![Some("the consumer unsubscribed from all topics".to_owned())],
5461 vec!["not subscribed to any topic".to_owned()],
5462 ),
5463 ),
5464 (
5465 "unsubscribe, then subscribe again",
5466 Change::UnsubscribeThenSubscribe,
5467 (
5468 vec![(
5469 vec!["orders".to_owned()],
5470 Some("consumer pro-actively leaving the group".to_owned()),
5471 )],
5472 vec![Some("the consumer unsubscribed from all topics".to_owned())],
5473 vec!["not subscribed to any topic".to_owned()],
5474 ),
5475 ),
5476 (
5477 "a pattern that matches no topic",
5478 Change::PatternWithoutMatch,
5479 (vec![(vec![], changed("orders", ""))], vec![], vec![]),
5480 ),
5481 ] {
5482 let StartedRangeConsumer {
5483 coordinator,
5484 mock,
5485 mut consumer,
5486 } = StartedRangeConsumer::start(None).await;
5487 let mut errors = Vec::new();
5488 match change {
5489 Change::Subscribe(topic) => consumer.subscribe([topic]).await.expect("subscribe"),
5490 Change::Unsubscribe => consumer.unsubscribe().await.expect("unsubscribe"),
5491 Change::UnsubscribeThenSubscribe => {
5492 consumer.unsubscribe().await.expect("unsubscribe");
5493 if let Err(error) = consumer.poll(millis(20)).await {
5494 errors.push(error.to_string());
5495 }
5496 consumer.subscribe(["orders"]).await.expect("subscribe");
5497 }
5498 Change::PatternWithoutMatch => {
5499 consumer
5500 .subscribe_pattern(TopicPattern::new(|_| false))
5501 .expect("subscribe_pattern");
5502 }
5503 }
5504 let deadline = Instant::now() + Duration::from_millis(500);
5505 while Instant::now() < deadline {
5506 if let Err(error) = consumer.poll(millis(20)).await
5507 && !errors.contains(&error.to_string())
5508 {
5509 errors.push(error.to_string());
5510 }
5511 if !coordinator.joins.lock().expect("joins lock").is_empty() {
5512 break;
5513 }
5514 sleep(Duration::from_millis(10)).await;
5515 }
5516 let joins = coordinator
5517 .joins
5518 .lock()
5519 .expect("joins lock")
5520 .iter()
5521 .map(|join| {
5522 let subscription = builder::decode_subscription(&join.protocols[0].metadata);
5523 (subscription.topics, join.reason.clone())
5524 })
5525 .collect();
5526 let leaves = coordinator
5527 .leaves
5528 .lock()
5529 .expect("leaves lock")
5530 .iter()
5531 .flat_map(|leave| leave.members.iter().map(|member| member.reason.clone()))
5532 .collect();
5533 drop(consumer);
5534 mock.stop();
5535 let outcome: SubscriptionOutcome = (joins, leaves, errors);
5536 actual.push((name, outcome));
5537 wanted.push((name, expected));
5538 }
5539 assert2::assert!(actual == wanted);
5540 }
5541
5542 #[tokio::test]
5545 async fn unsubscribe_gives_up_the_assignment_before_it_returns() {
5546 let coordinator = MockCoordinator::range_partition(0);
5547 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5548 let mut consumer = started_consumer(&mock, None).await;
5549 let before = consumer.assignment().await;
5550 consumer.coordinator_shutdown.cancel();
5553 if let Some(handle) = consumer.coordinator_handle.take() {
5554 handle.await.expect("coordinator task");
5555 }
5556 consumer.unsubscribe().await.expect("unsubscribe");
5557 let after = consumer.assignment().await;
5558 let owned = consumer.commit_identity.lock().await.ownership_ids.len();
5559 drop(consumer);
5560 mock.stop();
5561 assert2::assert!((before, after, owned) == (vec![partition(0)], vec![], 0));
5562 }
5563
5564 #[tokio::test]
5568 async fn a_manual_assignment_after_unsubscribe_survives_and_blocks_enforce_rebalance() {
5569 let coordinator = MockCoordinator::range_partition(0);
5570 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5571 let mut consumer = started_consumer(&mock, None).await;
5572 consumer.unsubscribe().await.expect("unsubscribe");
5573 consumer
5574 .assign(&[(TOPIC.to_owned(), 1)])
5575 .await
5576 .expect("assign");
5577 sleep(Duration::from_millis(300)).await;
5579 let assignment = consumer.assignment().await;
5580 let enforced = consumer
5581 .enforce_rebalance(None)
5582 .map_err(|error| error.to_string());
5583 let rejected = consumer
5584 .assign(&[(TOPIC.to_owned(), -1)])
5585 .await
5586 .map_err(|error| error.to_string());
5587 drop(consumer);
5588 mock.stop();
5589 assert2::assert!(
5590 (assignment, enforced, rejected)
5591 == (
5592 vec![(TOPIC.to_owned(), 1)],
5593 Err("illegal state: Tried to force a rebalance but the consumer has a manual assignment.".to_owned()),
5594 Err("invalid argument: Partition index of orders--1 is negative".to_owned())
5595 )
5596 );
5597 }
5598
5599 #[tokio::test]
5602 async fn enforce_rebalance_joins_on_the_next_poll_with_the_reason() {
5603 let mut actual = Vec::new();
5604 let mut wanted = Vec::new();
5605 for (name, reason, expected) in [
5606 ("no reason", None, "rebalance enforced by user"),
5607 ("empty reason", Some(""), "rebalance enforced by user"),
5608 ("reason", Some("scale out"), "scale out"),
5609 ] {
5610 let StartedRangeConsumer {
5611 coordinator,
5612 mock,
5613 mut consumer,
5614 } = StartedRangeConsumer::start(None).await;
5615 consumer
5616 .enforce_rebalance(reason)
5617 .expect("enforce_rebalance");
5618 let deadline = Instant::now() + Duration::from_secs(5);
5619 while coordinator.joins.lock().expect("joins lock").is_empty()
5620 && Instant::now() < deadline
5621 {
5622 consumer.poll(millis(20)).await.expect("poll");
5623 }
5624 let reasons: Vec<Option<String>> = coordinator
5625 .joins
5626 .lock()
5627 .expect("joins lock")
5628 .iter()
5629 .map(|join| join.reason.clone())
5630 .collect();
5631 drop(consumer);
5632 mock.stop();
5633 actual.push((name, reasons));
5634 wanted.push((name, vec![Some(expected.to_owned())]));
5635 }
5636 assert2::assert!(actual == wanted);
5637 }
5638
5639 #[tokio::test]
5643 async fn a_poll_that_waits_for_an_eager_join_starts_a_join_requested_after_it_began() {
5644 let coordinator = MockCoordinator::range_partition(0);
5645 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5646 let mut consumer = started_consumer(&mock, None).await;
5647 coordinator::note_poll(&consumer.poll_signal);
5649 coordinator.set_heartbeat_error(27);
5650 timeout(Duration::from_secs(5), async {
5651 while !*consumer.rebalance_pending.borrow() {
5652 sleep(Duration::from_millis(5)).await;
5653 }
5654 })
5655 .await
5656 .expect("the heartbeat requests a rebalance");
5657
5658 let started = Instant::now();
5659 let joined = consumer
5660 .wait_for_rebalance(started + Duration::from_secs(5))
5661 .await
5662 .expect("no listener");
5663 let within_timeout = started.elapsed() < Duration::from_secs(4);
5664 let joins = coordinator.joins.lock().expect("joins lock").len();
5665 drop(consumer);
5666 mock.stop();
5667 assert2::assert!((joined, within_timeout, joins) == (true, true, 1));
5668 }
5669
5670 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5674 async fn continuous_polls_do_not_stop_the_heartbeats() {
5675 let coordinator = MockCoordinator::range_partition(0);
5676 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5677 let consumer = started_consumer(&mock, None).await;
5678 let first_heartbeat = *coordinator
5679 .last_heartbeat
5680 .lock()
5681 .expect("last heartbeat lock");
5682 let poll_signal = consumer.poll_signal.clone();
5684 spawn_blocking(move || {
5685 let end = StdInstant::now() + Duration::from_millis(500);
5686 while StdInstant::now() < end {
5687 coordinator::note_poll(&poll_signal);
5688 }
5689 })
5690 .await
5691 .expect("poller");
5692 let heartbeat_during_polls = *coordinator
5693 .last_heartbeat
5694 .lock()
5695 .expect("last heartbeat lock")
5696 > first_heartbeat + Duration::from_millis(200);
5697 drop(consumer);
5698 mock.stop();
5699 assert2::assert!(heartbeat_during_polls);
5700 }
5701
5702 #[tokio::test]
5706 async fn the_poll_timeout_leave_goes_out_before_the_join_of_the_next_poll() {
5707 let coordinator = MockCoordinator::range_partition(0);
5708 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5709 let mut config = start_config(mock.addr.to_string(), Assignor::Range, false, millis(200));
5710 config.heartbeat_interval = millis(50);
5711 let consumer = super::auto_commit_tests::start_single_partition_consumer(config).await;
5712 timeout(Duration::from_secs(5), async {
5714 while !*consumer.rebalance_pending.borrow() {
5715 yield_now().await;
5716 }
5717 })
5718 .await
5719 .expect("the poll timeout expires");
5720 coordinator::note_poll(&consumer.poll_signal);
5721 timeout(Duration::from_secs(5), async {
5722 while coordinator.sync_groups() == 0 {
5723 sleep(Duration::from_millis(10)).await;
5724 }
5725 })
5726 .await
5727 .expect("the member joins again");
5728 let order: Vec<GroupRequest> = coordinator
5729 .requests()
5730 .into_iter()
5731 .filter(|request| matches!(request, GroupRequest::LeaveGroup | GroupRequest::JoinGroup))
5732 .take(2)
5733 .collect();
5734 drop(consumer);
5735 mock.stop();
5736 assert2::assert!(order == vec![GroupRequest::LeaveGroup, GroupRequest::JoinGroup]);
5737 }
5738
5739 #[tokio::test]
5744 async fn rejoin_sends_a_subscription_per_assignor_with_the_owned_partitions_of_the_protocol() {
5745 use builder::{DecodedSubscription, decode_subscription};
5746 let subscription =
5747 |owned: Vec<(String, i32)>, user_data: Option<Bytes>| DecodedSubscription {
5748 topics: vec![TOPIC.to_owned()],
5749 owned,
5750 generation_id: 1,
5751 rack_id: None,
5752 user_data,
5753 };
5754 let generation_one = Some(Bytes::from_static(&[0, 0, 0, 1]));
5755 let mut actual = Vec::new();
5756 let mut wanted = Vec::new();
5757 for (name, assignors, expected) in [
5758 (
5759 "default list, eager",
5760 Assignor::DEFAULT_LIST.to_vec(),
5761 vec![
5762 ("range".to_owned(), subscription(Vec::new(), None)),
5763 (
5764 "cooperative-sticky".to_owned(),
5765 subscription(Vec::new(), generation_one.clone()),
5766 ),
5767 ],
5768 ),
5769 (
5770 "cooperative sticky, cooperative",
5771 vec![Assignor::CooperativeSticky],
5772 vec![(
5773 "cooperative-sticky".to_owned(),
5774 subscription(vec![partition(0)], generation_one),
5775 )],
5776 ),
5777 (
5778 "sticky, eager",
5779 vec![Assignor::Sticky],
5780 vec![(
5781 "sticky".to_owned(),
5782 subscription(
5783 Vec::new(),
5784 assignor::subscription_user_data(
5785 Assignor::Sticky,
5786 Some(&[partition(0)]),
5787 1,
5788 ),
5789 ),
5790 )],
5791 ),
5792 ] {
5793 let coordinator = MockCoordinator::range_partition(0);
5794 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
5795 let consumer = started_consumer_with(&mock, None, &assignors).await;
5796 coordinator.set_heartbeat_error(27);
5797 let protocols = timeout(Duration::from_secs(10), async {
5798 loop {
5799 coordinator::note_poll(&consumer.poll_signal);
5800 if let Some(join) = coordinator.joins.lock().expect("joins lock").first() {
5801 break join
5802 .protocols
5803 .iter()
5804 .map(|protocol| {
5805 (
5806 protocol.name.clone(),
5807 decode_subscription(&protocol.metadata),
5808 )
5809 })
5810 .collect::<Vec<_>>();
5811 }
5812 sleep(Duration::from_millis(20)).await;
5813 }
5814 })
5815 .await
5816 .expect("the consumer joins again");
5817 drop(consumer);
5818 mock.stop();
5819 actual.push((name, protocols));
5820 wanted.push((name, expected));
5821 }
5822 assert2::assert!(actual == wanted);
5823 }
5824
5825 #[tokio::test]
5830 async fn join_group_carries_the_rejoin_reason() {
5831 let mut actual = Vec::new();
5832 let mut wanted = Vec::new();
5833 for (name, heartbeat_error, expected) in [
5834 (
5835 "rebalance in progress",
5836 27,
5837 ("member-a", "group is already rebalancing"),
5838 ),
5839 (
5840 "illegal generation",
5841 22,
5842 (
5843 "member-a",
5844 "encountered ILLEGAL_GENERATION from HEARTBEAT response",
5845 ),
5846 ),
5847 (
5848 "unknown member id",
5849 25,
5850 ("", "encountered UNKNOWN_MEMBER_ID from HEARTBEAT response"),
5851 ),
5852 ] {
5853 let fixture = StartedRangeConsumer::start(None).await;
5854 fixture
5855 .coordinator
5856 .heartbeat_error
5857 .store(heartbeat_error, Ordering::SeqCst);
5858 let join = timeout(Duration::from_secs(10), async {
5859 loop {
5860 coordinator::note_poll(&fixture.consumer.poll_signal);
5861 if let Some(join) = fixture
5862 .coordinator
5863 .joins
5864 .lock()
5865 .expect("joins lock")
5866 .first()
5867 {
5868 break (join.member_id.clone(), join.reason.clone());
5869 }
5870 sleep(Duration::from_millis(20)).await;
5871 }
5872 })
5873 .await
5874 .expect("the consumer joins again");
5875 drop(fixture.consumer);
5876 fixture.mock.stop();
5877 actual.push((name, join));
5878 wanted.push((name, (expected.0.to_owned(), Some(expected.1.to_owned()))));
5879 }
5880 assert2::assert!(actual == wanted);
5881 }
5882}
5883
5884#[cfg(test)]
5885mod rebalance_listener_tests {
5886 use std::sync::atomic::Ordering;
5887
5888 use super::{
5889 auto_commit_tests::{GroupRequest, MockCoordinator, TOPIC, partition, start_config},
5890 *,
5891 };
5892 use crate::{
5893 ConsumerRebalanceListener, RebalanceListenerError,
5894 test_support::{ListenerStep as Step, RecordingListener},
5895 };
5896
5897 #[derive(Clone, Debug, PartialEq)]
5899 enum Call {
5900 Revoked(Vec<(String, i32)>),
5901 Assigned(Vec<(String, i32)>),
5902 Lost(Vec<(String, i32)>),
5903 CommitInRevoke(Result<(), String>),
5904 AwaitingCallback(Vec<(String, i32)>),
5907 PollError(String),
5909 Position(Option<i64>),
5911 }
5912
5913 struct Recorder {
5914 calls: Arc<StdMutex<Vec<Call>>>,
5915 commit_on_revoke: bool,
5916 fail_revoke: bool,
5917 seek_on_assign: Option<i64>,
5918 }
5919
5920 impl Recorder {
5921 fn record(&self, call: Call) {
5922 self.calls.lock().expect("calls lock").push(call);
5923 }
5924 }
5925
5926 #[async_trait::async_trait]
5927 impl ConsumerRebalanceListener for Recorder {
5928 async fn on_partitions_revoked(
5929 &mut self,
5930 consumer: &Consumer,
5931 partitions: &[(String, i32)],
5932 ) -> Result<(), RebalanceListenerError> {
5933 self.record(Call::Revoked(partitions.to_vec()));
5934 if self.commit_on_revoke {
5935 let result = consumer
5936 .commit_sync()
5937 .await
5938 .map_err(|error| error.to_string());
5939 self.record(Call::CommitInRevoke(result));
5940 }
5941 if self.fail_revoke {
5942 return Err("revoke failed".into());
5943 }
5944 Ok(())
5945 }
5946
5947 async fn on_partitions_assigned(
5948 &mut self,
5949 consumer_for_assignment: &Consumer,
5950 assigned_partitions: &[(String, i32)],
5951 ) -> Result<(), RebalanceListenerError> {
5952 self.record(Call::Assigned(assigned_partitions.to_vec()));
5953 let mut awaiting: Vec<_> = consumer_for_assignment
5954 .assigned_callback_pending
5955 .lock()
5956 .expect("pending lock")
5957 .iter()
5958 .cloned()
5959 .collect();
5960 awaiting.sort();
5961 if !assigned_partitions.is_empty() {
5962 self.record(Call::AwaitingCallback(awaiting));
5963 }
5964 if let (Some(offset), Some(partition)) =
5965 (self.seek_on_assign, assigned_partitions.first())
5966 {
5967 consumer_for_assignment
5968 .seek(partition.0.clone(), partition.1, offset)
5969 .await
5970 .expect("seek");
5971 }
5972 Ok(())
5973 }
5974
5975 async fn on_partitions_lost(
5976 &mut self,
5977 _consumer: &Consumer,
5978 partitions: &[(String, i32)],
5979 ) -> Result<(), RebalanceListenerError> {
5980 self.record(Call::Lost(partitions.to_vec()));
5981 Ok(())
5982 }
5983 }
5984
5985 #[derive(Clone, Copy, Debug)]
5987 enum Event {
5988 Heartbeat(i16),
5990 Close,
5992 Subscribe(&'static str),
5994 }
5995
5996 struct Case {
5997 assignor: Assignor,
5998 owned: Vec<(String, i32)>,
5999 assignments: Vec<Vec<(String, i32)>>,
6001 event: Event,
6002 commit_on_revoke: bool,
6003 fail_revoke: bool,
6004 seek_on_assign: Option<i64>,
6005 calls: usize,
6007 }
6008
6009 #[derive(Debug, PartialEq)]
6012 struct CaseResult {
6013 calls: Vec<Call>,
6014 commit_generations: Vec<i32>,
6015 }
6016
6017 #[derive(bon::Builder)]
6018 struct ListenerScenario {
6019 name: &'static str,
6020 case: Case,
6021 expected_calls: Vec<Call>,
6022 expected_commit_generations: Vec<i32>,
6023 }
6024
6025 async fn run_case(case: Case) -> CaseResult {
6026 let coordinator = MockCoordinator::new(case.assignor, case.assignments);
6027 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
6028 let mut config = start_config(mock.addr.to_string(), case.assignor, false, minutes(1));
6029 config.heartbeat_interval = millis(50);
6030 config.has_rebalance_listener = true;
6031 let mut consumer = super::auto_commit_tests::start_consumer_with_startup(
6032 config,
6033 StartupState::builder()
6034 .generation_id(1)
6035 .assigned_partitions(case.owned.clone())
6036 .next_offsets(case.owned.iter().map(|key| (key.clone(), 12)).collect())
6037 .topic_partitions(maplit::hashmap! {TOPIC.to_owned() => 2})
6038 .build(),
6039 )
6040 .await;
6041 let calls = Arc::new(StdMutex::new(Vec::new()));
6042 consumer.rebalance_listener = Some(rebalance_listener::shared(Box::new(Recorder {
6043 calls: Arc::clone(&calls),
6044 commit_on_revoke: case.commit_on_revoke,
6045 fail_revoke: case.fail_revoke,
6046 seek_on_assign: case.seek_on_assign,
6047 })));
6048
6049 consumer.poll(millis(20)).await.expect("first poll");
6051 if case.seek_on_assign.is_some() {
6052 let position = consumer
6053 .next_offsets
6054 .lock()
6055 .await
6056 .get(&partition(0))
6057 .copied();
6058 calls
6059 .lock()
6060 .expect("calls lock")
6061 .push(Call::Position(position));
6062 }
6063 match case.event {
6064 event @ (Event::Heartbeat(_) | Event::Subscribe(_)) => {
6065 match event {
6066 Event::Heartbeat(error_code) => coordinator
6067 .heartbeat_error
6068 .store(error_code, Ordering::SeqCst),
6069 Event::Subscribe(topic) => {
6070 consumer.subscribe([topic]).await.expect("subscribe");
6071 }
6072 Event::Close => {}
6073 }
6074 let deadline = Instant::now() + Duration::from_secs(5);
6075 while calls.lock().expect("calls lock").len() < case.calls
6076 && Instant::now() < deadline
6077 {
6078 poll_recording(&mut consumer, &calls).await;
6079 }
6080 for _ in 0..10 {
6082 poll_recording(&mut consumer, &calls).await;
6083 }
6084 drop(consumer);
6085 }
6086 Event::Close => consumer.close().await.expect("close"),
6087 }
6088 let generations = coordinator
6089 .requests()
6090 .into_iter()
6091 .filter_map(|request| match request {
6092 GroupRequest::OffsetCommit(commit) => Some(commit.generation_id_or_member_epoch),
6093 _ => None,
6094 })
6095 .collect();
6096 mock.stop();
6097 let calls = calls.lock().expect("calls lock").clone();
6098 CaseResult {
6099 calls,
6100 commit_generations: generations,
6101 }
6102 }
6103
6104 async fn assert_listener_scenarios(scenarios: impl IntoIterator<Item = ListenerScenario>) {
6105 for scenario in scenarios {
6106 let expected = CaseResult {
6107 calls: scenario.expected_calls,
6108 commit_generations: scenario.expected_commit_generations,
6109 };
6110 assert2::assert!(
6111 run_case(scenario.case).await == expected,
6112 "{}",
6113 scenario.name
6114 );
6115 }
6116 }
6117
6118 async fn poll_recording(consumer: &mut Consumer, calls: &StdMutex<Vec<Call>>) {
6120 if let Err(error) = consumer.poll(millis(20)).await {
6121 calls
6122 .lock()
6123 .expect("calls lock")
6124 .push(Call::PollError(error.to_string()));
6125 }
6126 }
6127
6128 #[tokio::test]
6132 async fn a_cancelled_poll_keeps_the_listener_and_runs_the_callback_again() {
6133 let coordinator = MockCoordinator::new(
6134 Assignor::CooperativeSticky,
6135 vec![vec![partition(0), partition(1)]],
6136 );
6137 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
6138 let mut config = start_config(
6139 mock.addr.to_string(),
6140 Assignor::CooperativeSticky,
6141 false,
6142 minutes(1),
6143 );
6144 config.heartbeat_interval = millis(50);
6145 config.has_rebalance_listener = true;
6146 let mut consumer = super::auto_commit_tests::start_consumer_with_startup(
6147 config,
6148 super::auto_commit_tests::single_partition_startup(2),
6149 )
6150 .await;
6151 let steps = Arc::new(StdMutex::new(Vec::new()));
6152 let listener = RecordingListener::builder()
6153 .steps(Arc::clone(&steps))
6154 .slow_partition(1)
6155 .slow_for(Duration::from_millis(500))
6156 .build();
6157 consumer.rebalance_listener = Some(rebalance_listener::shared(Box::new(listener)));
6158 consumer.poll(millis(20)).await.expect("first poll");
6159 coordinator.set_heartbeat_error(27);
6160 let ended = |steps: &StdMutex<Vec<Step>>| {
6161 steps
6162 .lock()
6163 .expect("steps lock")
6164 .contains(&Step::Ended("assigned", vec![partition(1)]))
6165 };
6166 let deadline = Instant::now() + Duration::from_secs(5);
6167 while !ended(&steps) && Instant::now() < deadline {
6168 let _ = timeout(Duration::from_millis(100), consumer.poll(millis(20))).await;
6170 }
6171 let awaiting = consumer
6172 .assigned_callback_pending
6173 .lock()
6174 .expect("pending lock")
6175 .len();
6176 consumer.close().await.expect("close");
6177 mock.stop();
6178 let steps = steps.lock().expect("steps lock").clone();
6179 assert2::assert!(
6180 (steps, awaiting)
6181 == (
6182 vec![
6183 Step::Started("assigned", vec![partition(0)]),
6184 Step::Ended("assigned", vec![partition(0)]),
6185 Step::Started("assigned", vec![partition(1)]),
6186 Step::Started("assigned", vec![partition(1)]),
6187 Step::Ended("assigned", vec![partition(1)]),
6188 Step::Started("revoked", vec![partition(0), partition(1)]),
6189 Step::Ended("revoked", vec![partition(0), partition(1)]),
6190 ],
6191 0
6192 )
6193 );
6194 }
6195
6196 #[tokio::test]
6200 async fn poll_timeout_during_a_callback_leaves_the_group() {
6201 let coordinator = MockCoordinator::new(
6202 Assignor::CooperativeSticky,
6203 vec![vec![partition(0), partition(1)]],
6204 );
6205 let mock = super::auto_commit_tests::mock_coordinator_broker(&coordinator).await;
6206 let mut config = start_config(
6207 mock.addr.to_string(),
6208 Assignor::CooperativeSticky,
6209 false,
6210 millis(400),
6211 );
6212 config.heartbeat_interval = millis(50);
6213 config.has_rebalance_listener = true;
6214 let mut consumer = start_test_consumer(
6215 TestConsumerStart::builder()
6216 .bootstrap(mock.addr.to_string())
6217 .config(config)
6218 .startup(super::auto_commit_tests::single_partition_startup(2))
6219 .build(),
6220 )
6221 .await;
6222 let listener = RecordingListener::builder()
6223 .slow_partition(1)
6224 .slow_for(Duration::from_millis(1200))
6225 .build();
6226 consumer.rebalance_listener = Some(rebalance_listener::shared(Box::new(listener)));
6227 consumer.poll(millis(20)).await.expect("first poll");
6228 coordinator.set_heartbeat_error(27);
6229 let deadline = Instant::now() + Duration::from_secs(5);
6231 while coordinator.leaves.lock().expect("leaves lock").is_empty()
6232 && Instant::now() < deadline
6233 {
6234 let _ = consumer.poll(millis(20)).await;
6235 }
6236 let reasons: Vec<Option<String>> = coordinator
6237 .leaves
6238 .lock()
6239 .expect("leaves lock")
6240 .iter()
6241 .flat_map(|leave| leave.members.iter().map(|member| member.reason.clone()))
6242 .collect();
6243 drop(consumer);
6244 mock.stop();
6245 assert2::assert!(reasons == vec![Some("consumer poll timeout has expired.".to_owned())]);
6246 }
6247
6248 fn case(
6250 assignor: Assignor,
6251 owned: Vec<(String, i32)>,
6252 assignment: Vec<(String, i32)>,
6253 event: Event,
6254 calls: usize,
6255 ) -> Case {
6256 Case {
6257 assignor,
6258 owned,
6259 assignments: vec![assignment],
6260 event,
6261 commit_on_revoke: false,
6262 fail_revoke: false,
6263 seek_on_assign: None,
6264 calls,
6265 }
6266 }
6267
6268 fn awaiting(partitions: Vec<(String, i32)>) -> Call {
6269 Call::AwaitingCallback(partitions)
6270 }
6271
6272 fn empty_assignment_call() -> Call {
6273 Call::Assigned(Vec::new())
6274 }
6275
6276 fn range_revoke_calls() -> Vec<Call> {
6277 vec![
6278 Call::Assigned(vec![partition(0)]),
6279 awaiting(vec![]),
6280 Call::Revoked(vec![partition(0)]),
6281 ]
6282 }
6283
6284 fn range_revoke_commit_calls() -> Vec<Call> {
6285 let mut calls = range_revoke_calls();
6286 calls.extend([
6287 Call::CommitInRevoke(Ok(())),
6288 Call::Assigned(vec![partition(0)]),
6289 awaiting(vec![partition(0)]),
6290 ]);
6291 calls
6292 }
6293
6294 #[tokio::test]
6295 async fn listener_calls_follow_kafkas_order() {
6296 let p = partition;
6297 Box::pin(assert_listener_scenarios([
6298 ListenerScenario::builder()
6299 .name("eager rebalance")
6300 .case(case(
6301 Assignor::Range,
6302 vec![p(0), p(1)],
6303 vec![p(0)],
6304 Event::Heartbeat(27),
6305 5,
6306 ))
6307 .expected_calls(vec![
6308 Call::Assigned(vec![p(0), p(1)]),
6309 awaiting(vec![]),
6310 Call::Revoked(vec![p(0), p(1)]),
6311 Call::Assigned(vec![p(0)]),
6312 awaiting(vec![p(0)]),
6313 ])
6314 .expected_commit_generations(vec![])
6315 .build(),
6316 ListenerScenario::builder()
6317 .name("cooperative rebalance that revokes")
6318 .case(case(
6319 Assignor::CooperativeSticky,
6320 vec![p(0), p(1)],
6321 vec![p(0)],
6322 Event::Heartbeat(27),
6323 5,
6324 ))
6325 .expected_calls(vec![
6326 Call::Assigned(vec![p(0), p(1)]),
6327 awaiting(vec![]),
6328 Call::Revoked(vec![p(1)]),
6329 empty_assignment_call(),
6330 empty_assignment_call(),
6331 ])
6332 .expected_commit_generations(vec![])
6333 .build(),
6334 ListenerScenario::builder()
6335 .name("cooperative rebalance that adds")
6336 .case(case(
6337 Assignor::CooperativeSticky,
6338 vec![p(0)],
6339 vec![p(0), p(1)],
6340 Event::Heartbeat(27),
6341 4,
6342 ))
6343 .expected_calls(vec![
6344 Call::Assigned(vec![p(0)]),
6345 awaiting(vec![]),
6346 Call::Assigned(vec![p(1)]),
6347 awaiting(vec![p(1)]),
6348 ])
6349 .expected_commit_generations(vec![])
6350 .build(),
6351 ListenerScenario::builder()
6352 .name("unknown member id")
6353 .case(case(
6354 Assignor::Range,
6355 vec![p(0)],
6356 vec![p(0)],
6357 Event::Heartbeat(25),
6358 5,
6359 ))
6360 .expected_calls(vec![
6361 Call::Assigned(vec![p(0)]),
6362 awaiting(vec![]),
6363 Call::Lost(vec![p(0)]),
6364 Call::Assigned(vec![p(0)]),
6365 awaiting(vec![p(0)]),
6366 ])
6367 .expected_commit_generations(vec![])
6368 .build(),
6369 ListenerScenario::builder()
6370 .name("close")
6371 .case(case(
6372 Assignor::Range,
6373 vec![p(0)],
6374 vec![p(0)],
6375 Event::Close,
6376 3,
6377 ))
6378 .expected_calls(range_revoke_calls())
6379 .expected_commit_generations(vec![])
6380 .build(),
6381 ListenerScenario::builder()
6382 .name("a commit in the eager revoke callback")
6383 .case(Case {
6384 commit_on_revoke: true,
6385 ..case(
6386 Assignor::Range,
6387 vec![p(0)],
6388 vec![p(0)],
6389 Event::Heartbeat(27),
6390 6,
6391 )
6392 })
6393 .expected_calls(range_revoke_commit_calls())
6394 .expected_commit_generations(vec![1])
6395 .build(),
6396 ]))
6397 .await;
6398 }
6399
6400 #[tokio::test]
6403 async fn listener_calls_follow_kafka_around_commits_errors_and_seeks() {
6404 let p = partition;
6405 assert_listener_scenarios([
6406 ListenerScenario::builder()
6407 .name("a commit in the cooperative revoke callback carries the new generation")
6408 .case(Case {
6409 commit_on_revoke: true,
6410 ..case(
6411 Assignor::CooperativeSticky,
6412 vec![p(0), p(1)],
6413 vec![p(0)],
6414 Event::Heartbeat(27),
6415 6,
6416 )
6417 })
6418 .expected_calls(vec![
6419 Call::Assigned(vec![p(0), p(1)]),
6420 awaiting(vec![]),
6421 Call::Revoked(vec![p(1)]),
6422 Call::CommitInRevoke(Ok(())),
6423 empty_assignment_call(),
6424 empty_assignment_call(),
6425 ])
6426 .expected_commit_generations(vec![2])
6427 .build(),
6428 ListenerScenario::builder()
6429 .name("a failed cooperative revoke callback fails the poll after the rebalance")
6430 .case(Case {
6431 fail_revoke: true,
6432 ..case(
6433 Assignor::CooperativeSticky,
6434 vec![p(0), p(1)],
6435 vec![p(0)],
6436 Event::Heartbeat(27),
6437 6,
6438 )
6439 })
6440 .expected_calls(vec![
6441 Call::Assigned(vec![p(0), p(1)]),
6442 awaiting(vec![]),
6443 Call::Revoked(vec![p(1)]),
6444 empty_assignment_call(),
6445 empty_assignment_call(),
6446 Call::PollError("rebalance listener failed: revoke failed".into()),
6447 ])
6448 .expected_commit_generations(vec![])
6449 .build(),
6450 ListenerScenario::builder()
6451 .name("a cooperative join after a subscription change revokes the partitions of the old topic")
6452 .case(case(
6453 Assignor::CooperativeSticky,
6454 vec![p(0), p(1)],
6455 vec![],
6456 Event::Subscribe("payments"),
6457 4,
6458 ))
6459 .expected_calls(vec![
6460 Call::Assigned(vec![p(0), p(1)]),
6461 awaiting(vec![]),
6462 Call::Revoked(vec![p(0), p(1)]),
6463 empty_assignment_call(),
6464 ])
6465 .expected_commit_generations(vec![])
6466 .build(),
6467 ListenerScenario::builder()
6468 .name("a seek in the assign callback moves the position of the same poll")
6469 .case(Case {
6470 seek_on_assign: Some(3),
6471 ..case(Assignor::Range, vec![p(0)], vec![p(0)], Event::Close, 4)
6472 })
6473 .expected_calls(vec![
6474 Call::Assigned(vec![p(0)]),
6475 awaiting(vec![]),
6476 Call::Position(Some(3)),
6477 Call::Revoked(vec![p(0)]),
6478 ])
6479 .expected_commit_generations(vec![])
6480 .build(),
6481 ])
6482 .await;
6483 }
6484}
6485
6486#[cfg(test)]
6487mod transport_configuration_tests {
6488 use assert2::assert;
6489 use bytes::BytesMut;
6490 use krabka_client_core::MockBroker;
6491 use krabka_protocol::{
6492 Encode,
6493 owned::{api_versions_request, api_versions_response::ApiVersionsResponse},
6494 };
6495 use krabka_units::{kibibytes, millis};
6496 use tokio::time::Instant;
6497
6498 use super::{ClientError, Consumer};
6499
6500 #[tokio::test]
6501 async fn consumer_retains_dns_and_socket_overrides_in_its_client() {
6502 let mock = MockBroker::start(|api, _, _, _| {
6503 if api != api_versions_request::API_KEY {
6504 return None;
6505 }
6506 let mut body = BytesMut::new();
6507 ApiVersionsResponse::default()
6508 .encode(&mut body, 0)
6509 .expect("encode ApiVersions");
6510 Some(body.to_vec())
6511 })
6512 .await
6513 .unwrap();
6514 let consumer = Consumer::builder()
6515 .bootstrap(mock.addr.to_string())
6516 .request_timeout(millis(10))
6517 .dns_timeout(millis(95))
6518 .send_buffer(None)
6519 .receive_buffer(Some(kibibytes(19)))
6520 .enable_metrics_push(false)
6521 .build()
6522 .await
6523 .expect("build consumer");
6524 let options = consumer.client.connection_options();
6525 assert!(
6526 (
6527 options.dns_timeout.time(),
6528 options.send_buffer,
6529 options.receive_buffer
6530 ) == (millis(95), None, Some(kibibytes(19)))
6531 );
6532 assert!(consumer.client.refresh_metadata().await.is_err());
6533 consumer.close().await.expect("close consumer");
6534 mock.stop();
6535 }
6536
6537 #[tokio::test(start_paused = true)]
6538 async fn consumer_total_startup_deadline_bounds_attempts_and_retry_sleeps() {
6539 use krabka_units::{convert::TimeExt as _, secs};
6540
6541 use super::ConsumerRetryPolicy;
6542 for backoff in [millis(100), secs(1), secs(2)] {
6543 let mock = MockBroker::start(|_, _, _, _| None).await.unwrap();
6544 let policy = ConsumerRetryPolicy::builder()
6545 .startup_attempt_timeout(secs(2))
6546 .startup_deadline(secs(3))
6547 .startup_initial_backoff(backoff)
6548 .startup_max_backoff(backoff)
6549 .coordinator_retry_timeout(secs(1))
6550 .coordinator_initial_backoff(millis(100))
6551 .coordinator_max_backoff(secs(1))
6552 .build()
6553 .expect("policy");
6554 let started = Instant::now();
6555 let result = Consumer::builder()
6556 .bootstrap(mock.addr.to_string())
6557 .group_id("startup-guard")
6558 .subscribe(["startup-topic".to_owned()])
6559 .retry_policy(policy)
6560 .enable_metrics_push(false)
6561 .build()
6562 .await;
6563 assert!(matches!(result, Err(super::ConsumerError::Client(
6564 ClientError::Timeout(deadline)
6565 )) if deadline == secs(3)));
6566 assert!(started.elapsed() == secs(3).to_std());
6567 mock.stop();
6568 }
6569 }
6570
6571 #[tokio::test]
6572 async fn consumer_rejects_invalid_dns_before_retrying_bootstrap() {
6573 let result = Consumer::builder()
6574 .bootstrap("127.0.0.1:1")
6575 .dns_timeout(millis(0))
6576 .build()
6577 .await;
6578 assert!(matches!(
6579 result,
6580 Err(super::ConsumerError::InvalidConfig(_))
6581 ));
6582 }
6583}