Skip to main content

krabka_client_consumer/
consumer.rs

1//! `Consumer`, the public lifecycle handle. Build it with
2//! [`Consumer::builder`].
3//!
4//! A consumer with `subscribe` or `subscribe_pattern` joins a group. A
5//! consumer without a subscription uses [`Consumer::assign`], with or without a
6//! group id.
7
8use 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
87/// The milliseconds of `value`, a duration that
88/// [`validated_millisecond_duration`] accepted: a whole number of milliseconds
89/// that fits `u64`.
90fn 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/// Subscribe-style consumer handle. Construct via [`Consumer::builder`].
117#[allow(dead_code)] // `session_timeout` / `heartbeat_interval`
118// are captured for diagnostics; the live values are owned
119// by the coordinator task post-start.
120pub struct Consumer {
121    pub(crate) client: Client,
122    pub(crate) group_id: String,
123    /// Node id of the group's coordinator broker. `FindCoordinator` discovers it
124    /// at build time, and the coordinator task keeps it current through the
125    /// shared `Arc<AtomicI32>`. The commit path (`commit.rs`) reads it to route
126    /// `OffsetCommit` to the coordinator over this data-path client.
127    pub(crate) coordinator_id: Arc<AtomicI32>,
128    pub(crate) retry_policy: CoordinatorRetryPolicy,
129    /// The live member id. The coordinator task publishes each change, for
130    /// example the new id of a join from scratch.
131    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    /// The callbacks of the asynchronous commits that wait for a snapshot.
136    pub(crate) commit_async_callbacks: commit::OffsetCommitCallbacks,
137    pub(crate) group_instance_id: Option<String>,
138    /// The current group generation exposed by [`Consumer::generation`].
139    /// Commit RPCs use the generation atomically paired with membership and
140    /// partition ownership in `commit_identity`.
141    pub(crate) current_generation: Arc<AtomicI32>,
142    /// The subscription, shared with the coordinator task.
143    pub(crate) subscription: subscription::SharedSubscription,
144    /// Kafka's `group.protocol`.
145    pub(crate) group_protocol: crate::GroupProtocol,
146    /// Current assigned partitions: `(topic, partition_index)`.
147    pub(crate) assigned: Arc<Mutex<Vec<(String, i32)>>>,
148    /// Wakes selected-offset commits after assignment publication.
149    pub(crate) assignment_changed: Arc<Notify>,
150    /// Next offset to fetch per partition.
151    pub(crate) next_offsets: Arc<Mutex<HashMap<(String, i32), i64>>>,
152    /// Last broker-reported readable end offset per partition.
153    pub(crate) end_offsets: Arc<Mutex<HashMap<(String, i32), i64>>>,
154    /// KIP-320 per-partition leader-epoch metadata, keyed like `next_offsets`.
155    pub(crate) positions: Arc<Mutex<HashMap<(String, i32), position::PartitionPosition>>>,
156    /// Topic UUIDs resolved at build time. Fetch v ≥ 13 needs them, because it
157    /// carries `topic_id` instead of the topic name.
158    pub(crate) topic_ids: Arc<Mutex<HashMap<String, WireUuid>>>,
159    pub(crate) session_timeout: Time,
160    pub(crate) heartbeat_interval: Time,
161    /// The newest rebalance protocol that every configured assignor supports.
162    pub(crate) rebalance_protocol: assignor::RebalanceProtocol,
163    pub(crate) coordinator_shutdown: CancellationToken,
164    pub(crate) coordinator_handle: Option<JoinHandle<()>>,
165    /// Controls which records `poll` returns.
166    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    /// Kafka's `fetch.max.wait.ms`, the `max_wait_ms` of each Fetch.
171    pub(crate) fetch_max_wait: Time,
172    /// The in-flight Fetch requests and the fetch session of each broker.
173    pub(crate) fetches: poll::Fetches,
174    /// Kafka's `client.rack`, the `rack_id` of each Fetch (KIP-392).
175    pub(crate) client_rack: Option<String>,
176    /// Kafka's `metadata.max.age.ms`. A preferred read replica expires after it.
177    pub(crate) metadata_max_age: Time,
178    /// Kafka's `request.timeout.ms`. It bounds the requests of `close`.
179    pub(crate) request_timeout: Time,
180    /// Wakes a `poll` from another task.
181    pub(crate) wakeup: control::WakeupHandle,
182    /// Rebalances that the application asks for, with their reasons.
183    pub(crate) enforced_rebalances: mpsc::UnboundedSender<(String, u64)>,
184    /// Kafka's `default.api.timeout.ms`, the timeout of `position` and
185    /// `committed`.
186    pub(crate) default_api_timeout: Time,
187    /// The partitions that the application paused.
188    pub(crate) paused: PausedPartitions,
189    /// What `poll` does on a missing offset or a detected truncation. `None`
190    /// surfaces `ConsumerError::LogTruncation`. Any other value makes `poll`
191    /// apply the safe offset, per KIP-320.
192    pub(crate) auto_offset_reset: AutoOffsetReset,
193    /// A fatal error from a coordinator rejoin that the next `poll` returns.
194    /// The coordinator task holds the same slot.
195    pub(crate) poll_error: coordinator::PollErrorSlot,
196    /// Kafka's `enable.auto.commit` state. `None` when auto commit is off.
197    /// The coordinator task holds a clone.
198    pub(crate) auto_commit: Option<commit::AutoCommit>,
199    /// Counts the `poll` calls that got no error. The coordinator task waits
200    /// for it after a fatal error.
201    pub(crate) poll_signal: coordinator::PollSignal,
202    /// `true` while the member must join the group. The coordinator task
203    /// writes it.
204    pub(crate) rebalance_pending: watch::Receiver<bool>,
205    /// Kafka's `max.poll.records`.
206    pub(crate) max_poll_records: usize,
207    /// Fetched records that `poll` did not return yet.
208    pub(crate) fetch_buffer: fetch_buffer::FetchBuffer,
209    /// The membership operation of `close`. The coordinator task reads it
210    /// when it stops.
211    pub(crate) close_operation: watch::Sender<control::CloseRequest>,
212    /// The rebalance listener, Kafka's `ConsumerRebalanceListener`.
213    pub(crate) rebalance_listener: Option<rebalance_listener::SharedListener>,
214    /// The listener calls that the coordinator task asks `poll` to run.
215    pub(crate) listener_calls: mpsc::UnboundedReceiver<rebalance_listener::ListenerCall>,
216    /// The added partitions that wait for `on_partitions_assigned`. The
217    /// coordinator task writes it and `poll` does not fetch them.
218    pub(crate) assigned_callback_pending: rebalance_listener::AssignedCallbackPending,
219}
220
221/// What a closing consumer does with its group membership. Kafka's
222/// `CloseOptions.GroupMembershipOperation` (KIP-1092).
223#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
224pub enum GroupMembershipOperation {
225    /// Send `LeaveGroup`, also for a static member.
226    LeaveGroup,
227    /// Stay in the group until the session timeout, also for a dynamic
228    /// member.
229    RemainInGroup,
230    /// A dynamic member leaves the group. A static member (with a
231    /// `group_instance_id`) stays in the group, so that a restart with the
232    /// same instance id does not start a rebalance (KIP-345).
233    #[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    /// `true` after a fatal coordinator error removed the member from the
243    /// group, until the join that the next `poll` starts completes. A commit
244    /// fails with `CommitFailed` while it is set.
245    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    /// Kafka's `max.poll.interval.ms`, also the `JoinGroup` rebalance timeout.
255    max_poll_interval: Time,
256    /// Kafka's `max.poll.records`.
257    max_poll_records: usize,
258    heartbeat_interval: Time,
259    subscription_metadata_refresh_interval: Time,
260    subscribe: Vec<String>,
261    /// The pattern of a pattern subscription. `subscribe` then holds the
262    /// topics that matched at the build.
263    subscribe_pattern: Option<crate::TopicPattern>,
264    /// The regular expression of a broker-side pattern subscription.
265    subscribe_regex: Option<String>,
266    /// Kafka's `exclude.internal.topics`.
267    exclude_internal_topics: bool,
268    /// Kafka's `group.protocol`.
269    group_protocol: crate::GroupProtocol,
270    /// Kafka's `group.remote.assignor`, for the consumer group protocol.
271    server_assignor: Option<String>,
272    group_instance_id: Option<String>,
273    auto_offset_reset: AutoOffsetReset,
274    isolation_level: IsolationLevel,
275    /// Kafka's `partition.assignment.strategy`.
276    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    /// Kafka's `client.dns.lookup`.
296    client_dns_lookup: krabka_client_core::ClientDnsLookup,
297    /// Kafka's `reconnect.backoff.ms`.
298    reconnect_backoff: Time,
299    /// Kafka's `reconnect.backoff.max.ms`.
300    reconnect_backoff_max: Time,
301    /// Kafka's `connections.max.idle.ms`.
302    connections_max_idle: Time,
303    /// Kafka's `socket.connection.setup.timeout.ms`.
304    socket_connection_setup_timeout: Time,
305    /// Kafka's `socket.connection.setup.timeout.max.ms`.
306    socket_connection_setup_timeout_max: Time,
307    /// Whether the application set a rebalance listener.
308    has_rebalance_listener: bool,
309    retry_policy: ConsumerRetryPolicy,
310    /// Kafka's `auto.commit.interval.ms`, or `None` when
311    /// `enable.auto.commit` is off.
312    auto_commit_interval: Option<Duration>,
313    /// Kafka's `allow.auto.create.topics`.
314    allow_auto_create_topics: bool,
315    /// The KIP-714 reporter of the consumer's client, or `None` when Kafka's
316    /// `enable.metrics.push` is off.
317    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/// Validated classic Consumer startup and coordinator retry timing.
342#[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
353/// Kafka's default `retry.backoff.max.ms`.
354pub const DEFAULT_CONSUMER_RETRY_BACKOFF_MAX: Time = millis(1000);
355
356#[bon::bon]
357impl ConsumerRetryPolicy {
358    /// Construct a validated retry policy.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error for non-positive, non-finite, fractional-millisecond,
363    /// or inconsistently ordered values.
364    #[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    /// Per-attempt startup timeout.
422    #[must_use]
423    pub fn startup_attempt_timeout(self) -> Time {
424        self.startup_attempt_timeout.time()
425    }
426
427    /// Wall-clock startup deadline.
428    #[must_use]
429    pub fn startup_deadline(self) -> Time {
430        self.startup_deadline.time()
431    }
432
433    /// Initial startup retry backoff.
434    #[must_use]
435    pub fn startup_initial_backoff(self) -> Time {
436        self.startup_initial_backoff.time()
437    }
438
439    /// Maximum startup retry backoff.
440    #[must_use]
441    pub fn startup_max_backoff(self) -> Time {
442        self.startup_max_backoff.time()
443    }
444
445    /// Coordinator operation retry timeout.
446    #[must_use]
447    pub fn coordinator_retry_timeout(self) -> Time {
448        self.coordinator_retry_timeout.time()
449    }
450
451    /// Initial coordinator retry backoff.
452    #[must_use]
453    pub fn coordinator_initial_backoff(self) -> Time {
454        self.coordinator_initial_backoff.time()
455    }
456
457    /// Maximum coordinator retry backoff.
458    #[must_use]
459    pub fn coordinator_max_backoff(self) -> Time {
460        self.coordinator_max_backoff.time()
461    }
462
463    /// The default retry policy, with Kafka's `retry.backoff.max.ms`
464    /// (`retry_backoff_max`) capping both the startup and the coordinator
465    /// exponential retry backoff.
466    ///
467    /// The fixed startup and coordinator initial backoffs are clamped to
468    /// `retry_backoff_max` so that a low configured maximum caps every
469    /// startup retry instead of failing construction.
470    ///
471    /// # Errors
472    ///
473    /// Returns an error if `retry_backoff_max` does not validate, for example
474    /// a negative or fractional-millisecond value.
475    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    /// The defaults of [`Self::new`], which `with_retry_backoff_max` also
497    /// gives for `DEFAULT_CONSUMER_RETRY_BACKOFF_MAX`.
498    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
512/// Default deadline for classic Consumer best-effort group departure.
513pub const DEFAULT_CONSUMER_LEAVE_GROUP_TIMEOUT: Time = secs(5);
514
515/// Positive, whole-millisecond classic Consumer leave-group deadline.
516#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
517pub struct ConsumerLeaveGroupTimeout(Duration);
518
519impl ConsumerLeaveGroupTimeout {
520    /// Validate a leave-group timeout.
521    ///
522    /// # Errors
523    ///
524    /// Returns an error for zero, fractional milliseconds, or a value whose
525    /// milliseconds cannot be represented as `u64`.
526    pub fn new(value: Duration) -> Result<Self, String> {
527        validated_millisecond_duration("consumer leave-group timeout", value)?;
528        Ok(Self(value))
529    }
530
531    /// Return the validated duration.
532    #[must_use]
533    pub const fn duration(self) -> Duration {
534        self.0
535    }
536
537    /// Return the validated duration in milliseconds.
538    #[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        // A whole, positive number of milliseconds, as `new` requires.
547        Self(DEFAULT_CONSUMER_LEAVE_GROUP_TIMEOUT.to_std())
548    }
549}
550
551/// Default cadence for checking subscribed-topic metadata changes.
552pub const DEFAULT_CONSUMER_SUBSCRIPTION_METADATA_REFRESH_INTERVAL: Time = secs(5);
553
554/// Positive total response-byte budget for one classic consumer fetch.
555#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
556pub struct ConsumerFetchMaxBytes(i32);
557
558impl ConsumerFetchMaxBytes {
559    /// Validate a total fetch byte budget.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error when `value` is zero or negative.
564    pub fn new(value: i32) -> Result<Self, String> {
565        validated_positive_i32(value, "consumer fetch max bytes").map(Self)
566    }
567
568    /// Return the validated protocol byte count.
569    #[must_use]
570    pub const fn bytes(self) -> i32 {
571        self.0
572    }
573
574    /// Return the validated byte count as a dimensioned quantity.
575    #[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/// Positive per-partition response-byte budget for one classic consumer fetch.
590#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
591pub struct ConsumerFetchPartitionMaxBytes(i32);
592
593impl ConsumerFetchPartitionMaxBytes {
594    /// Validate a per-partition fetch byte budget.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error when `value` is zero or negative.
599    pub fn new(value: i32) -> Result<Self, String> {
600        validated_positive_i32(value, "consumer fetch partition max bytes").map(Self)
601    }
602
603    /// Return the validated protocol byte count.
604    #[must_use]
605    pub const fn bytes(self) -> i32 {
606        self.0
607    }
608
609    /// Return the validated byte count as a dimensioned quantity.
610    #[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/// Positive, whole-millisecond subscribed-topic metadata refresh cadence.
625#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
626pub struct ConsumerSubscriptionMetadataRefreshInterval(Duration);
627
628impl ConsumerSubscriptionMetadataRefreshInterval {
629    /// Validate a subscribed-topic metadata refresh interval.
630    ///
631    /// # Errors
632    ///
633    /// Returns an error for zero, fractional milliseconds, or a value whose
634    /// milliseconds cannot be represented as `u64`.
635    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    /// Return the validated duration.
641    #[must_use]
642    pub const fn duration(self) -> Duration {
643        self.0
644    }
645
646    /// Return the validated duration in milliseconds.
647    #[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        // A whole, positive number of milliseconds, as `new` requires.
656        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/// A per-record header key/value pair, as the Kafka v2 record format defines
667/// it. The key is a UTF-8 string. The value is optional raw bytes.
668#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct Header {
670    pub key: String,
671    pub value: Option<Bytes>,
672}
673
674/// The meaning of a record timestamp, as bit 3 of the v2 record batch
675/// attributes gives it.
676///
677/// Kafka's `TimestampType` also has `NoTimestampType` for v0 messages. This
678/// consumer reads only v2 record batches, so that value cannot occur.
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
680pub enum TimestampType {
681    /// The producer set the timestamp when it created the record.
682    CreateTime,
683    /// The broker set the timestamp when it appended the batch to the log.
684    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/// One record returned by `Consumer::poll`.
697///
698/// The key and the value are the raw record bytes. Kafka's
699/// `serializedKeySize` and `serializedValueSize` are their lengths, or -1 for
700/// `None`, so this type does not keep them as separate fields.
701#[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    /// The create time, or for a `LogAppendTime` batch the broker append
708    /// time (the batch `max_timestamp`).
709    pub timestamp: i64,
710    /// The timestamp type from the attributes of the record batch.
711    pub timestamp_type: TimestampType,
712    pub key: Option<Bytes>,
713    pub value: Option<Bytes>,
714    pub headers: Vec<Header>,
715}
716
717/// The `JoinGroup` protocols of the first join: no owned partitions, no
718/// generation and no last assignment.
719fn 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
727/// The `JoinGroup` fields of the startup join. Kafka's first
728/// `AbstractCoordinator.rejoinReason` is empty.
729fn 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
794/// The topics that the pattern of `config` matches now. Kafka's consumer
795/// matches a pattern against all topics of the cluster
796/// (`ConsumerMetadata.newMetadataRequestBuilder` asks for all topics).
797async 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
810/// The commit identity of the assignment of the build, with the next
811/// ownership id.
812fn 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
827/// Queue the assign callback of the assignment of the build. Kafka's first
828/// `poll` completes the first join and calls `on_partitions_assigned`. Here the
829/// build joined, so the first `poll` runs this call.
830fn 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
842/// The metadata scope of a consumer with a topic list subscription. Kafka's
843/// `ConsumerMetadata.newMetadataRequestBuilder` names the subscribed topics
844/// and asks for all topics only for a client-side pattern subscription.
845fn 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
897/// The placeholder that a partition with no committed offset starts at, by
898/// `auto_offset_reset` policy.
899///
900/// Kafka's `AutoOffsetResetStrategy.EARLIEST.timestamp()` is
901/// `ListOffsetsRequest.EARLIEST_TIMESTAMP` (-2): `earliest` always asks the
902/// broker for the true log start with a `ListOffsets` round trip, and never
903/// reports a position before it completes. A hardcoded 0 would be wrong once
904/// retention has moved the log start past it, and the first `Fetch` would
905/// answer `OFFSET_OUT_OF_RANGE`. `none` never auto-resets at all: Kafka's
906/// `SubscriptionState.resetInitializingPositions` raises
907/// `NoOffsetForPartitionException` from `poll()` and `position()` instead
908/// (`OffsetFetcherUtils.offsetResetStrategyWithValidTimestamp`).
909pub fn reset_starting_offset(auto_offset_reset: AutoOffsetReset) -> i64 {
910    match auto_offset_reset {
911        // Resolved by poll() via ListOffsets(-2), the same sentinel as
912        // `seek_to_beginning`.
913        AutoOffsetReset::Earliest => poll::BEGINNING_SENTINEL,
914        // Resolved by poll() via ListOffsets(-1) on first call.
915        AutoOffsetReset::Latest | AutoOffsetReset::ByDuration(_) => poll::LATEST_SENTINEL,
916        // Never resolved: poll() and position() raise NoOffsetForPartition.
917        AutoOffsetReset::None => poll::NO_OFFSET_SENTINEL,
918    }
919}
920
921/// Kafka's default `default.api.timeout.ms`.
922pub const DEFAULT_CONSUMER_DEFAULT_API_TIMEOUT: Time = secs(60);
923
924/// Kafka's default `max.poll.interval.ms`.
925pub const DEFAULT_CONSUMER_MAX_POLL_INTERVAL: Time = minutes(5);
926
927/// Kafka's `ConsumerConfig.CONSUMER_CLIENT_ID_SEQUENCE`.
928static CONSUMER_CLIENT_ID_SEQUENCE: AtomicU64 = AtomicU64::new(1);
929
930/// The `client.id` of a consumer. Kafka's `ConsumerConfig.maybeOverrideClientId`
931/// generates `consumer-<group.id>-<group.instance.id>` for a static member,
932/// and `consumer-<group.id>-<n>` with a process-wide sequence otherwise, when
933/// no client id or an empty one is set.
934fn 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
953/// Kafka's default `metadata.max.age.ms`.
954pub const DEFAULT_CONSUMER_METADATA_MAX_AGE: Time = minutes(5);
955
956/// Kafka's default `fetch.max.wait.ms`.
957pub const DEFAULT_CONSUMER_FETCH_MAX_WAIT: Time = millis(500);
958
959/// The validated `fetch_max_wait`.
960///
961/// Kafka's `ConsumerConfig` defines `fetch.max.wait.ms` as an `int` of at least
962/// 0.
963fn 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
973/// Kafka's default `max.poll.records`.
974pub const DEFAULT_CONSUMER_MAX_POLL_RECORDS: usize = 500;
975
976/// The validated `max_poll_records`.
977///
978/// Kafka's `ConsumerConfig` defines `max.poll.records` as an `int` of at least
979/// 1.
980fn 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
987/// The validated `max_poll_interval`.
988///
989/// Kafka's `ConsumerConfig` defines `max.poll.interval.ms` as an `int` of at
990/// least 1. The interval must therefore be a whole number of milliseconds from
991/// 1 to `i32::MAX`.
992fn 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
1002/// The auto commit interval, or `None` when auto commit is off.
1003///
1004/// Kafka's `ConsumerConfig` defines `auto.commit.interval.ms` as an `int` of at
1005/// least 0. The interval must therefore be a whole number of milliseconds from
1006/// 0 to `i32::MAX`.
1007fn 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        // Wrap the committed leader epoch (raw wire `int32` from OffsetFetch) at
1028        // the decode boundary.
1029        offset_epoch: krabka_ids::LeaderEpoch(committed_epoch),
1030        ..Default::default()
1031    }
1032}
1033
1034/// A protocol `int32` millisecond field, truncated rather than rounded.
1035///
1036/// The coordinator compares every Kafka request field that this feeds against
1037/// its own configured bounds. The pre-quantity code reached those fields
1038/// through `Duration::as_millis`, which truncates. Rounding to nearest would
1039/// push a fractional-millisecond timeout one millisecond wider on the wire.
1040pub 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    /// Build a [`Consumer`] subscribed to the given topics.
1047    ///
1048    /// This function validates the configuration eagerly and fails fast before
1049    /// any network I/O. It then calls the internal `start_once` operation with a
1050    /// per-attempt timeout. An attempt can stall on a lost wakeup during
1051    /// cold-boot group-join contention, or return a transient error. The
1052    /// function then drops the timed-out future, which cancels its in-flight
1053    /// connections, and starts a fresh attempt. A genuine misconfiguration or a
1054    /// persistent error surfaces immediately.
1055    ///
1056    /// With `enable_auto_commit` on (the default, as in Kafka), the consumer
1057    /// commits its positions as Kafka's `enable.auto.commit` does: from `poll`
1058    /// each `auto_commit_interval` (default 5 s), before each `JoinGroup`, and
1059    /// in [`close`](Self::close). The commit before a `JoinGroup` includes only
1060    /// records that the application received before its latest `poll`. Set
1061    /// `enable_auto_commit(false)` to commit manually, for example in a
1062    /// transactional consume-process-produce loop. The consumer then commits
1063    /// nothing that the application does not ask for.
1064    ///
1065    /// `max_poll_interval` (default 5 min) is Kafka's `max.poll.interval.ms`.
1066    /// When the application does not call `poll` for longer, a dynamic member
1067    /// sends `LeaveGroup` and a static member stops its heartbeats. The next
1068    /// `poll` joins the group again. The consumer also sends the value as the
1069    /// `JoinGroup` rebalance timeout. The consumer starts a rebalance that the
1070    /// coordinator asks for only in `poll`, so the assignment does not change
1071    /// for the eager protocol while the application processes the records of
1072    /// the last `poll`.
1073    #[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    /// # Errors
1082    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
1083    pub async fn start(
1084        #[builder(into)] bootstrap: String,
1085        #[builder(into)] client_id: Option<String>,
1086        /// Kafka's `group.id`. Without it, the consumer has no group: use
1087        /// [`assign`](Self::assign), and do not commit.
1088        #[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        /// Kafka's `subscribe(Pattern)`: subscribe to the topics that the
1098        /// pattern matches. Set this or `subscribe`.
1099        subscribe_pattern: Option<crate::TopicPattern>,
1100        /// Kafka's `exclude.internal.topics` (default `true`): a pattern does
1101        /// not match an internal topic.
1102        #[builder(default = true)]
1103        exclude_internal_topics: bool,
1104        /// Subscribe to the topics that this RE2 regular expression matches.
1105        /// Kafka's `subscribe(SubscriptionPattern)` (KIP-848): the group
1106        /// coordinator matches the expression, so it needs
1107        /// `group_protocol(GroupProtocol::Consumer)`.
1108        #[builder(into)]
1109        subscribe_regex: Option<String>,
1110        /// Kafka's `group.protocol`: `Classic` (the default) uses `JoinGroup`,
1111        /// `SyncGroup` and `Heartbeat`; `Consumer` uses
1112        /// `ConsumerGroupHeartbeat` with server-side assignment (KIP-848).
1113        #[builder(default)]
1114        group_protocol: crate::GroupProtocol,
1115        /// Kafka's `group.remote.assignor`: the assignor that the group
1116        /// coordinator runs, for example `uniform` or `range`. Only for
1117        /// `group_protocol(GroupProtocol::Consumer)`.
1118        #[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        /// Kafka's `fetch.min.bytes`, as a raw protocol byte count.
1125        #[builder(default = krabka_client_core::DEFAULT_FETCH_MIN.bytes_i32())]
1126        fetch_min: i32,
1127        /// Kafka's `fetch.max.bytes`, as a raw protocol byte count.
1128        #[builder(default = poll::DEFAULT_FETCH_MAX.bytes_i32())]
1129        fetch_max: i32,
1130        /// Kafka's `max.partition.fetch.bytes`, as a raw protocol byte count.
1131        #[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        /// Kafka's `default.api.timeout.ms`: the timeout of
1136        /// [`position`](Self::position), [`committed`](Self::committed),
1137        /// [`commit_sync`](Self::commit_sync) and
1138        /// [`commit_offsets_sync`](Self::commit_offsets_sync).
1139        #[builder(default = DEFAULT_CONSUMER_DEFAULT_API_TIMEOUT)]
1140        default_api_timeout: Time,
1141        #[builder(default = secs(30))] request_timeout: Time,
1142        /// Deadline for DNS lookup during client bootstrap.
1143        #[builder(default = krabka_client_core::DEFAULT_CLIENT_DNS_TIMEOUT)]
1144        dns_timeout: Time,
1145        /// Outbound socket send buffer. `None` selects the operating system default.
1146        #[builder(required, default = Some(krabka_client_core::DEFAULT_SEND_BUFFER))]
1147        send_buffer: Option<ByteSize>,
1148        /// Outbound socket receive buffer. `None` selects the operating system default.
1149        #[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        /// Kafka's `client.dns.lookup`.
1162        #[builder(default)]
1163        client_dns_lookup: krabka_client_core::ClientDnsLookup,
1164        /// Kafka's `reconnect.backoff.ms`.
1165        #[builder(default = krabka_client_core::DEFAULT_RECONNECT_BACKOFF)]
1166        reconnect_backoff: Time,
1167        /// Kafka's `reconnect.backoff.max.ms`.
1168        #[builder(default = krabka_client_core::DEFAULT_RECONNECT_BACKOFF_MAX)]
1169        reconnect_backoff_max: Time,
1170        /// Kafka's `connections.max.idle.ms`.
1171        #[builder(default = krabka_client_core::DEFAULT_CONNECTIONS_MAX_IDLE)]
1172        connections_max_idle: Time,
1173        /// Kafka's `socket.connection.setup.timeout.ms`.
1174        #[builder(default = krabka_client_core::DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT)]
1175        socket_connection_setup_timeout: Time,
1176        /// Kafka's `socket.connection.setup.timeout.max.ms`.
1177        #[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        /// Kafka's `retry.backoff.max.ms`: caps the startup and coordinator
1181        /// exponential retry backoff. Ignored when `retry_policy` is set.
1182        #[builder(default = DEFAULT_CONSUMER_RETRY_BACKOFF_MAX)]
1183        retry_backoff_max: Time,
1184        /// Overrides every retry timing, including `retry_backoff_max`, with a
1185        /// fully custom policy.
1186        retry_policy: Option<ConsumerRetryPolicy>,
1187        /// Kafka's `enable.auto.commit`: on by default with a group id, off
1188        /// without one.
1189        enable_auto_commit: Option<bool>,
1190        #[builder(default = secs(5))] auto_commit_interval: Time,
1191        /// Kafka's `allow.auto.create.topics`: the metadata requests name the
1192        /// subscribed topics and let a broker with
1193        /// `auto.create.topics.enable=true` create a missing one.
1194        #[builder(default = true)]
1195        allow_auto_create_topics: bool,
1196        /// Kafka's `enable.metrics.push` (default `true`): push the client
1197        /// metrics that a broker's client metrics subscription names
1198        /// (KIP-714), and a terminating push on close.
1199        #[builder(default = true)]
1200        enable_metrics_push: bool,
1201    ) -> Result<Self, ConsumerError> {
1202        // Fail fast on misconfig — before any retry loop.
1203        // Kafka treats an empty `group.id` as no group id.
1204        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        // Kafka's `ConsumerConfig.maybeOverrideEnableAutoCommit`.
1209        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        // Kafka's `ConsumerConfig.checkUnsupportedConfigsPostProcess`:
1217        // `group.remote.assignor` belongs to the consumer group protocol, and
1218        // `partition.assignment.strategy` to the classic one.
1219        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        // The two validated newtypes below still speak `Duration`: both derive
1276        // `Eq`, which an `f64`-backed quantity cannot satisfy. Their whole job
1277        // is to police the whole-millisecond invariant, so the quantity meets
1278        // them at their own boundary and comes straight back.
1279        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        // `retry_backoff_max` feeds the default retry policy only: an
1316        // explicit `retry_policy` already validated its own backoff caps.
1317        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    /// Retries [`Self::start_once`] with the timeout-and-backoff policy that
1389    /// [`Self::start`] describes, until it succeeds or a non-retriable error
1390    /// or the startup deadline ends the attempt loop.
1391    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    /// Single attempt to build a [`Consumer`]: resolve bootstrap, `JoinGroup`
1456    /// twice, compute the assignment if this member is the elected leader,
1457    /// `SyncGroup`, prime offsets, then spawn the coordinator task.
1458    ///
1459    /// [`Self::start`] calls this under a per-attempt timeout. A drop of the
1460    /// returned future at any point before the final `tokio::spawn` cancels all
1461    /// in-flight connections cleanly. This function spawns the coordinator task
1462    /// at its very end, with no `.await` after it, so a timed-out attempt can
1463    /// never orphan a coordinator task.
1464    #[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            // KIP-848: the member joins with its first
1490            // `ConsumerGroupHeartbeat`, which the coordinator task sends.
1491            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        // `JoinGroupRequest` carries these as `int32` milliseconds, and the
1548        // JVM coordinator compares them against its own configured bounds.
1549        // `Duration::as_millis` truncated here before the conversion, so keep
1550        // truncating: `millis_i32`'s round-to-nearest would widen a fractional
1551        // session timeout by a millisecond on the wire.
1552        let session_timeout_ms = protocol_millis_i32(session_timeout);
1553        // Kafka's `ClassicKafkaConsumer` sends `max.poll.interval.ms` as the
1554        // rebalance timeout.
1555        let rebalance_timeout_ms = protocol_millis_i32(max_poll_interval);
1556
1557        // 0. Discover the group's coordinator broker. Real Kafka (Strimzi)
1558        //    returns NOT_COORDINATOR (16) for any group RPC that doesn't reach
1559        //    the group's actual coordinator, so every Join/Sync/Heartbeat/Commit/
1560        //    Fetch/Leave must target it — not the arbitrary bootstrap broker.
1561        //    `find_coordinator` also `refresh_metadata`s the main client's pool
1562        //    so it learns the coordinator broker's address (needed by
1563        //    `client.broker(coordinator_id)` here and by `commit.rs`).
1564        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        // First JoinGroup uses empty `owned_partitions` + `generation_id=-1`:
1571        // we've never been in the group before, so we have nothing to claim
1572        // and no prior generation to defend against zombie ownership.
1573        let protocols = initial_join_protocols(&assignors, &subscribe, client_rack.as_deref())?;
1574
1575        // 1. First JoinGroup — empty member_id, expect MEMBER_ID_REQUIRED (79)
1576        //    or a regular response; either way the broker hands us a member_id.
1577        //    Routed to the coordinator broker, re-discovering it on a
1578        //    cold/relocating-coordinator code (14/15/16) before each retry.
1579        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    // Kafka's `JoinGroupResponseHandler` requests the second join with this
1651    // reason.
1652    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    // 2. Second JoinGroup with the assigned member_id, on the coordinator.
1660    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    // 5. Fetch existing committed offsets so poll() resumes correctly.
1683    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        // OffsetFetch is a coordinator RPC — route it to the coordinator
1691        // broker (its id is fresh from the join/sync above).
1692        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    /// The topics of all member subscriptions for a leader, else the own
1730    /// subscription.
1731    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    // 3. Always issue a Metadata to resolve topic_ids (needed for
1748    //    Fetch v ≥ 13). If we are the leader, also use the partition
1749    //    counts to compute the assignment.
1750    //    `refresh_metadata` (not a bare `send`) so the main client's
1751    //    BrokerPool learns each broker's (id → addr) mapping up front,
1752    //    letting `poll`/`validate` route to partition leaders immediately
1753    //    rather than waiting for the first `refresh_leader_epochs` pass.
1754    let is_leader = is_group_leader(&r2.leader, member_id);
1755    // Kafka's `ConsumerCoordinator.onLeaderElected`: the leader fetches
1756    // metadata for the topics of every member.
1757    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    // 4. SyncGroup — leader installs assignments; everyone receives their
1786    //    own assignment in the response. On the coordinator broker, with
1787    //    re-discovery on a cold/relocating-coordinator code.
1788    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    /// The topics that the coordinator task watches until the next join.
1843    #[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
1884/// Start a consumer without a subscription: Kafka's consumer before
1885/// `subscribe` or `assign`, and a consumer without `group.id`. It joins no
1886/// group and has no coordinator task. With a group id it finds the
1887/// coordinator, so it can commit the offsets of an `assign`.
1888async 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    // KIP-848: the member id comes from the client. Kafka's
1903    // `AsyncKafkaConsumer` generates a random one at startup.
1904    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
1922/// The client of the coordinator task, or `None` for a consumer without a
1923/// subscription, which has no coordinator task.
1924///
1925/// The broker processes requests serially per TCP connection: a `JoinGroup`
1926/// parked in the rebalance-join purgatory blocks every later request on that
1927/// socket. A dedicated coordinator connection keeps the data path (`poll`,
1928/// commit) independent of the group-protocol path.
1929async 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    // A leader of the build watches the topics of the whole group until the
1947    // next join (Kafka's `SubscriptionState.metadataTopics`).
1948    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    // A consumer without a subscription has no coordinator task.
2015    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    // Shared with the coordinator task so the commit path always stamps the
2031    // current generation; the coordinator publishes to it on every (re)join.
2032    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    // A member of the consumer group protocol has no assignment before its
2037    // first heartbeat, so the first `poll` waits for it.
2038    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    // A consumer that joined no group runs no rebalance callback.
2045    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(&current_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        // The metadata snapshot this initial assignment was computed against,
2088        // threaded to the coordinator so its rejoin baseline starts from
2089        // exactly what we saw here — not a fresh fetch that could already
2090        // include a topic created during start-up (which would strand a
2091        // cold-start empty assignment permanently).
2092        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    // IMPORTANT: `tokio::spawn` is the very last operation — no `.await`
2110    // follows it.  Dropping a timed-out `start_once` future before this
2111    // point cancels all in-flight connections without spawning anything.
2112    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
2169/// Returns `true` for a transient startup error, where a fresh build after a
2170/// drop of the half-built consumer is likely to succeed.
2171///
2172/// Returns `false` for a permanent misconfig or decode error, so those surface
2173/// immediately without pointless retries.
2174fn is_retriable_consumer_start_error(error: &ConsumerError) -> bool {
2175    // Retry only conditions that a fresh attempt a moment later is likely to
2176    // clear: transient group-protocol codes (the group is mid-rebalance or the
2177    // coordinator is warming up / relocating), and a connection dropped
2178    // mid-join. The lost-wakeup *hang* the retry loop exists to survive is NOT
2179    // an error here — it never returns; it is caught by the per-attempt
2180    // timeout. We deliberately do NOT retry `Connect`/`Timeout`: an unreachable
2181    // or non-responding broker is a genuine fault that must surface promptly
2182    // (and is the broker's `depends_on: healthy` to prevent at cold boot), not
2183    // be masked by a long retry storm.
2184    matches!(
2185        error,
2186        // 14 COORDINATOR_LOAD_IN_PROGRESS, 15 COORDINATOR_NOT_AVAILABLE,
2187        // 16 NOT_COORDINATOR, 22 ILLEGAL_GENERATION, 25 UNKNOWN_MEMBER_ID,
2188        // 27 REBALANCE_IN_PROGRESS, 79 MEMBER_ID_REQUIRED.
2189        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    /// The consumer's group id.
2204    #[must_use]
2205    pub fn group_id(&self) -> &str {
2206        &self.group_id
2207    }
2208
2209    /// The current member id. It is empty while the member is not in the group,
2210    /// and it changes when the member joins the group again from scratch.
2211    #[must_use]
2212    pub fn member_id(&self) -> String {
2213        self.member_id.borrow().clone()
2214    }
2215
2216    /// The current group generation. The coordinator task keeps it live across
2217    /// rejoins through the shared `Arc<AtomicI32>`.
2218    #[must_use]
2219    pub fn generation_id(&self) -> i32 {
2220        self.current_generation.load(Ordering::Relaxed)
2221    }
2222
2223    /// KIP-447 group metadata to hand to a transactional producer's
2224    /// `send_offsets_to_transaction`.
2225    ///
2226    /// The generation id is the coordinator's live generation, which the shared
2227    /// `Arc<AtomicI32>` keeps current across rejoins.
2228    #[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    /// Snapshot of currently assigned `(topic, partition)` pairs.
2239    pub async fn assignment(&self) -> Vec<(String, i32)> {
2240        self.assigned.lock().await.clone()
2241    }
2242
2243    /// Whether every assigned partition has consumed through the last end
2244    /// offset reported by a successful fetch.
2245    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    /// Close the consumer with [`GroupMembershipOperation::Default`]: commit
2262    /// with auto commit on, stop the coordinator task, and send `LeaveGroup`
2263    /// for a dynamic member. A static member stays in the group. See
2264    /// [`close_with`](Self::close_with).
2265    ///
2266    /// # Errors
2267    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
2268    pub async fn close(self) -> Result<(), ConsumerError> {
2269        self.close_with(CloseOptions::builder().build()).await
2270    }
2271
2272    /// The client instance id that the broker assigned for metrics push
2273    /// (KIP-714), as Kafka's `KafkaConsumer.clientInstanceId` returns it.
2274    ///
2275    /// The call waits up to `timeout` for the first
2276    /// `GetTelemetrySubscriptions` response. It returns `Ok(None)` when none
2277    /// has come by then, as Kafka returns `null`. A zero `timeout` does not
2278    /// wait.
2279    ///
2280    /// # Errors
2281    /// Returns [`ClientError::TelemetryDisabled`] when `enable_metrics_push`
2282    /// is `false`, and [`ClientError::InvalidArgument`] for a negative
2283    /// `timeout`, as Kafka throws an `IllegalStateException` and an
2284    /// `IllegalArgumentException`.
2285    ///
2286    /// [`ClientError::TelemetryDisabled`]: ClientError::TelemetryDisabled
2287    /// [`ClientError::InvalidArgument`]: ClientError::InvalidArgument
2288    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    /// Close the consumer, and leave the group or stay in it as
2296    /// `options.group_membership_operation` says. Kafka's
2297    /// `KafkaConsumer.close(CloseOptions)`.
2298    ///
2299    /// `options.timeout` (default 30 s) bounds the auto commit (at most
2300    /// `request_timeout`) and then the close of the fetch sessions, as Kafka's
2301    /// `ClassicKafkaConsumer.close` bounds the coordinator close and the
2302    /// fetcher close. The consumer always sends the `LeaveGroup` and waits
2303    /// for it for at most `leave_group_timeout`: Kafka's `maybeLeaveGroup`
2304    /// transmits the request (`pollNoWakeup`) before `awaitPendingRequests`
2305    /// checks the close timer. The rebalance listener call before the
2306    /// `LeaveGroup` is not bounded. A negative timeout gives
2307    /// [`ConsumerError::InvalidArgument`]; the consumer then stops without a
2308    /// commit and without `LeaveGroup`.
2309    ///
2310    /// The coordinator itself sends the best-effort `LeaveGroup` as the last
2311    /// thing it does on shutdown. See `coordinator::run`. It uses its
2312    /// *live* `member_id`, which can differ from the one captured at build
2313    /// time, because a from-scratch rejoin (`UNKNOWN_MEMBER_ID`) replaces it.
2314    /// The cancel and join are prompt, because the coordinator races its
2315    /// in-tick RPCs against the shutdown token.
2316    #[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    /// # Errors
2324    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
2325    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        // Kafka's `createTimerForRequest`: the coordinator close gets at most
2330        // `request.timeout.ms`.
2331        let coordinator_deadline = started + timeout.min(request_timeout);
2332        // Kafka's `ConsumerCoordinator.close` runs `maybeAutoCommitOffsetsSync`
2333        // before the coordinator leaves the group.
2334        self.auto_commit_on_close(coordinator_deadline).await;
2335        // Kafka's `ConsumerCoordinator.close` then waits for the pending
2336        // asynchronous commits and runs their callbacks, within the timer.
2337        self.wait_for_async_commits(coordinator_deadline).await;
2338        // Kafka's `ConsumerCoordinator.onLeavePrepare` runs the listener before
2339        // `LeaveGroup`: lost when the member has no generation or a rebalance
2340        // runs, revoked otherwise.
2341        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        // Kafka's `AbstractFetch.close` closes the fetch session of each
2350        // broker, within the time left and at most `request.timeout.ms`.
2351        let left = timeout
2352            .saturating_sub(started.elapsed())
2353            .min(request_timeout);
2354        self.close_fetch_sessions(left).await;
2355        // The terminating telemetry push (KIP-714), within the time left.
2356        let left = timeout.saturating_sub(started.elapsed());
2357        let _ = time::timeout(left, self.client.close_telemetry()).await;
2358        listener_result
2359    }
2360
2361    /// Run the listener calls that wait, then give the owned partitions to the
2362    /// listener before the consumer leaves the group.
2363    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    /// `session_timeout_ms` and `rebalance_timeout_ms` are `int32` wire fields
2399    /// that the group coordinator range-checks. The pre-quantity code reached
2400    /// them through `Duration::as_millis`, which truncates, so a fractional
2401    /// millisecond must still round *down*. `TimeExt::millis_i32` rounds to
2402    /// nearest and would report one millisecond more.
2403    #[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    /// The code keeps the saturation that the
2426    /// `i32::try_from(...).unwrap_or(i32::MAX)` guard provided. An absurd
2427    /// timeout clamps and does not wrap negative on the wire.
2428    #[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    /// Kafka's `retry.backoff.max.ms` caps both the startup and the
2479    /// coordinator exponential retry backoff. When it is below the fixed
2480    /// 500 ms/100 ms initial backoffs, construction still succeeds and the
2481    /// initial backoffs are clamped down to it instead of being rejected.
2482    #[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    /// A negative time below one millisecond is still negative, and fails the
2708    /// build before any request.
2709    #[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    /// A `retry_backoff_max` below the fixed 500 ms/100 ms startup and
2753    /// coordinator initial backoffs must still build: the initial backoffs
2754    /// are clamped down to it rather than making construction fail.
2755    #[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    /// `retry_backoff_max` (Kafka's `retry.backoff.max.ms`) validates through
2783    /// the same eager, pre-connection check as the other retry timings.
2784    #[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    /// A build needs exactly one of `subscribe` and `subscribe_pattern`, and
2801    /// no empty topic name (Kafka's `subscribe` throws
2802    /// `IllegalArgumentException` for an empty topic).
2803    #[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    /// The consumer group protocol options of KIP-848: Kafka's
2846    /// `ConsumerConfig.checkUnsupportedConfigsPostProcess`,
2847    /// `ClassicKafkaConsumer.subscribe(SubscriptionPattern)` and
2848    /// `AsyncKafkaConsumer.throwIfSubscriptionPatternIsInvalid`.
2849    #[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    /// Kafka's `ConsumerCoordinator.metadata` sends one protocol per assignor,
3005    /// in the configured order. The first join owns nothing and has no
3006    /// generation. `cooperative-sticky` carries its generation as user data,
3007    /// and `sticky` carries nothing before its first assignment.
3008    #[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    /// A refused start returns its error to the caller, so `start` records it
3199    /// at its span's own level, info, and nothing under it records ERROR
3200    /// (krabka-io/krabka-broker#1515).
3201    #[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        // A seek keeps the end offset of the last fetch, as Kafka's
3379        // `TopicPartitionState.seekValidated` keeps the high watermark.
3380        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    /// Regression: the generation that the commit path stamps must track the
3390    /// coordinator's joins and rejoins, not a start-up snapshot. The coordinator
3391    /// is the sole writer and publishes through the shared `current_generation`
3392    /// atomic. The accessor, the group metadata, and the commit path all read it
3393    /// live, so a commit issued after a rebalance carries the CURRENT generation
3394    /// instead of the stale one that the broker rejects with
3395    /// `ILLEGAL_GENERATION (22)`.
3396    #[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        // Simulate the coordinator publishing a new generation on rejoin.
3407        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    // --- is_retriable_consumer_start_error ---
3428
3429    #[test]
3430    fn retriable_error_classification() {
3431        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3432
3433        use krabka_client_core::ClientError;
3434
3435        // Transient group-protocol server codes.
3436        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        // Non-transient server code (e.g. INVALID_REQUEST = 42).
3444        assert2::assert!(!is_retriable_consumer_start_error(&ConsumerError::Server(
3445            42
3446        )));
3447
3448        for (_name, error, expected) in [
3449            // A connection dropped mid-join is transient — retry a fresh attempt.
3450            (
3451                "disconnected",
3452                ConsumerError::Client(ClientError::Disconnected),
3453                true,
3454            ),
3455            // Connect/Timeout are NOT retried: an unreachable or non-responding
3456            // broker is a genuine fault that must surface promptly (the lost-wakeup
3457            // hang the retry loop survives never returns Timeout — it is caught by
3458            // the per-attempt timeout, not classified here).
3459            (
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            // Permanent misconfig errors — must NOT be retriable.
3480            ("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        // 127.0.0.1:1 is unroutable; the consumer build connects eagerly
3514        // (JoinGroup), so it must fail — proving the security arg is
3515        // threaded (not a type error).
3516        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    /// `REBALANCE_IN_PROGRESS`.
3570    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    /// How long a rebalance step waits for the `SyncGroup` requests. The first
3574    /// heartbeat comes after 3 s.
3575    const REBALANCE_WAIT: Duration = Duration::from_secs(30);
3576
3577    /// A group request that the mock coordinator received, or an event of the
3578    /// application, in arrival order.
3579    #[derive(Clone, Debug, PartialEq)]
3580    pub(super) enum GroupRequest {
3581        OffsetCommit(OffsetCommitRequest),
3582        JoinGroup,
3583        SyncGroup,
3584        LeaveGroup,
3585        /// `commit_sync` returned to the application.
3586        CommitSyncReturned,
3587        /// The coordinator did not receive the expected `SyncGroup` requests in
3588        /// the time of the step.
3589        SyncGroupLate,
3590        /// `close` did not return in the time of the step.
3591        CloseTimedOut,
3592        /// The `JoinGroup` came more than the session timeout after the last
3593        /// heartbeat. A broker would have removed the member.
3594        SessionExpired,
3595    }
3596
3597    /// How the mock coordinator answers one `OffsetCommit`.
3598    #[derive(Clone, Copy, Debug)]
3599    pub(super) enum CommitReply {
3600        /// Send no response.
3601        Drop,
3602        /// Answer each partition with this error code.
3603        Error(i16),
3604    }
3605
3606    /// The API versions that the mock advertises: `(api_key, min, max)`. Each
3607    /// maximum is below the flexible version of its API, so no response needs a
3608    /// tagged response header.
3609    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    /// A group coordinator that records every group request. It answers each
3631    /// one with success, except the `OffsetCommit` requests in
3632    /// `commit_replies`. It does not answer `FindCoordinator`.
3633    pub(super) struct MockCoordinator {
3634        protocol: &'static str,
3635        requests: StdMutex<Vec<GroupRequest>>,
3636        /// The error code of the next `Heartbeat` response. The mock sends it
3637        /// once and then answers `0`.
3638        pub(super) heartbeat_error: AtomicI16,
3639        /// The assignment of each `SyncGroup` response. The mock repeats the
3640        /// last one.
3641        assignments: StdMutex<VecDeque<Vec<(String, i32)>>>,
3642        generation: AtomicI32,
3643        /// The answers to the next `OffsetCommit` requests. An empty queue
3644        /// answers with success.
3645        pub(super) commit_replies: StdMutex<VecDeque<CommitReply>>,
3646        /// When `true`, the next `OffsetCommit` makes the next `Heartbeat`
3647        /// answer `REBALANCE_IN_PROGRESS`.
3648        rebalance_on_commit: AtomicBool,
3649        /// When the last `Heartbeat` or `JoinGroup` came.
3650        pub(super) last_heartbeat: StdMutex<Instant>,
3651        /// When `true`, the mock does not answer a `Heartbeat` without an error
3652        /// in `heartbeat_error`.
3653        drop_heartbeats: AtomicBool,
3654        /// Each decoded `JoinGroup` request.
3655        pub(super) joins: StdMutex<Vec<JoinGroupRequest>>,
3656        /// Each decoded `LeaveGroup` request.
3657        pub(super) leaves: StdMutex<Vec<LeaveGroupRequest>>,
3658        /// When `true`, the mock does not answer `LeaveGroup`.
3659        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        /// A coordinator at generation 1 whose `SyncGroup` responses carry
3719        /// `assignments`.
3720        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                        // The empty tagged fields of the request header.
3793                        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                // No partition has records.
3831                fetch_request::API_KEY => Some(encode(&FetchResponse::default(), version)),
3832                // Every partition resolves to offset 0, whichever timestamp the
3833                // request asked for.
3834                list_offsets_request::API_KEY => {
3835                    Some(test_support::list_offsets_mock_response(&mut body, version))
3836                }
3837                // No partition has a committed offset.
3838                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                        // The empty tagged fields of the request header.
3848                        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    /// The `OffsetCommit` that Kafka's `SubscriptionState.allConsumed` makes:
3910    /// each `(partition, offset, leader_epoch)` with metadata `""`.
3911    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    /// Kafka's `ConsumerMetadata.newMetadataRequestBuilder` names the
3941    /// subscribed topics, with `allowAutoTopicCreation` from
3942    /// `allow.auto.create.topics`.
3943    #[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                    // The test stops at the first `JoinGroup`.
3987                    _ => 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    /// Kafka's leader watches the topics of the whole group
4012    /// (`SubscriptionState.metadataTopics` after `groupSubscribe`). The
4013    /// coordinator task of a consumer that led the join of its build names
4014    /// them in its metadata requests.
4015    #[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    /// One application or group event of a scenario.
4136    #[derive(Clone, Copy, Debug)]
4137    enum Step {
4138        /// Run the start of `poll`, and wait for a commit that it sends.
4139        Poll,
4140        /// Advance the clock by the auto commit interval.
4141        AdvanceInterval,
4142        /// `poll` returns records of partition 0 up to offset 19. The
4143        /// application has not processed them until it polls again.
4144        ReceiveRecords,
4145        /// The next heartbeat answers `REBALANCE_IN_PROGRESS`. Wait until the
4146        /// coordinator received `syncs` `SyncGroup` requests in total, or
4147        /// record `SyncGroupLate` after `within`.
4148        Rebalance { syncs: usize, within: Duration },
4149        /// Run `commit_sync`.
4150        CommitSync,
4151        /// The mock answers the next `OffsetCommit` with this reply, and the
4152        /// next heartbeat after that commit answers `REBALANCE_IN_PROGRESS`.
4153        /// Run `commit_sync`, and wait as `Rebalance` does.
4154        CommitSyncDuringRebalance {
4155            reply: CommitReply,
4156            syncs: usize,
4157            within: Duration,
4158        },
4159        /// The mock answers the next `OffsetCommit` with this reply.
4160        ReplyToNextCommit(CommitReply),
4161        /// Take the commit lock and keep it until the scenario ends.
4162        HoldCommitLock,
4163        /// The mock stops to answer heartbeats, except a heartbeat that
4164        /// answers an error.
4165        DropHeartbeats,
4166        /// Run `close`, or record `CloseTimedOut` after two minutes.
4167        Close,
4168    }
4169
4170    /// Wait until the coordinator received `syncs` `SyncGroup` requests in
4171    /// total, or record `SyncGroupLate` after `within`. The application polls
4172    /// each 100 ms meanwhile, so that the coordinator task starts the joins.
4173    /// These polls record no new positions.
4174    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        // Let a commit that comes after the last SyncGroup arrive.
4191        sleep(Duration::from_secs(1)).await;
4192    }
4193
4194    /// Start a consumer that owns partitions 0 (next offset 12, leader epoch 3)
4195    /// and 1 (next offset 7, no epoch) at generation 1, run `steps`, and return
4196    /// the group requests that the coordinator received.
4197    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    /// [`run`] with a rebalance timeout.
4207    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                        // The result does not matter here: the scenario checks
4287                        // the order of the requests.
4288                        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    /// One auto-commit scenario and the requests it sends to the group.
4332    #[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    /// Kafka commits the consumed positions from `poll` each
4441    /// `auto.commit.interval.ms`, before each `JoinGroup`, and in `close`, but
4442    /// only when `enable.auto.commit` is on. With auto commit off, the consumer
4443    /// commits nothing that the application did not ask for.
4444    #[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    /// The commit before a `JoinGroup` and the commit of `close` wait for the
4528    /// other commits of the consumer, so the committed offset does not move
4529    /// back. The rebalance timeout bounds the commit before a `JoinGroup`, and
4530    /// the close timeout bounds the commit of `close`. A commit that waits for
4531    /// the rebalance does not block the rebalance.
4532    #[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                    // `default_api_timeout` (60s), not `coordinator_retry_timeout`
4571                    // (30s), now bounds `commit_sync`'s retries, so it has time
4572                    // left to retry the dropped request once more before the
4573                    // rebalance it triggered is detected.
4574                    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    /// `FENCED_INSTANCE_ID`.
4729    const FENCED_INSTANCE_ID: i16 = 82;
4730
4731    /// What the application saw around a fence of its static member.
4732    #[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    /// The coordinator fences the static member on a heartbeat. `poll` returns
4744    /// the error once, a commit fails, and the member sends no `JoinGroup`
4745    /// until the application polls again. That `poll` joins the group and gets
4746    /// a new assignment. Kafka's `AbstractCoordinator.pollHeartbeat` raises the
4747    /// failure cause once, and the next `poll` calls `ensureActiveGroup`.
4748    #[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        // Wait for the heartbeat that gets the fence.
4764        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        // Several heartbeat intervals without a poll.
4778        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    /// How the application polls in a poll timeout case, with a
4842    /// `max_poll_interval` of 500 ms. The polls before the timeout only signal
4843    /// the coordinator task, as the start of `poll` does. The case runs in real
4844    /// time: with paused time, the network wait of a request lets the clock
4845    /// jump past the poll deadline.
4846    #[derive(Clone, Copy, Debug)]
4847    enum Polls {
4848        /// One `poll` each 100 ms.
4849        EachTenthOfASecond,
4850        /// No `poll` for 700 ms.
4851        Late,
4852    }
4853
4854    /// What the group saw in a poll timeout case.
4855    #[derive(Debug, PartialEq)]
4856    struct PollTimeoutObservation {
4857        leaves: Vec<LeaveGroupRequest>,
4858        heartbeats_after_timeout: bool,
4859        commit: Result<(), String>,
4860        /// `Consumer::member_id` before the next `poll`.
4861        member_id_before_next_poll: String,
4862        /// `(member_id, group_instance_id)` of each `JoinGroup` after the next
4863        /// `poll`.
4864        joins_after_next_poll: Vec<(String, Option<String>)>,
4865        /// `Consumer::member_id` after the join of the next `poll`.
4866        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        // The case starts after the first heartbeat, with a `poll`.
4880        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        // Four heartbeat intervals.
4909        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        // The application keeps polling while the join runs.
4924        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    /// Kafka's heartbeat thread sends `LeaveGroup` with the reason "consumer
4953    /// poll timeout has expired." when no `poll` came for
4954    /// `max.poll.interval.ms`, for a dynamic member only
4955    /// (`AbstractCoordinator.handlePollTimeoutExpiry`, `maybeLeaveGroup`). Every
4956    /// member resets its generation, so heartbeats stop, a commit fails with
4957    /// `CommitFailedException`, and the next `poll` joins with an empty member
4958    /// id.
4959    #[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    /// The poll timeout clears the assignment before the `LeaveGroup` goes out.
5014    /// A `poll` while the coordinator does not answer the `LeaveGroup` fetches
5015    /// nothing, and it starts the join after the leave request ends.
5016    #[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    /// Kafka's `ClassicKafkaConsumer` sends `max.poll.interval.ms` as the
5052    /// `JoinGroup` rebalance timeout. The default is 300000 ms.
5053    #[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    /// Kafka's `ConsumerConfig.maybeOverrideClientId`: an unset or empty
5087    /// `client.id` becomes `consumer-<group.id>-<group.instance.id>`, or
5088    /// `consumer-<group.id>-<n>` with a process-wide sequence.
5089    #[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    /// Kafka's `AbstractCoordinator.close` calls `maybeLeaveGroup` with the
5232    /// `CloseOptions` operation. `shouldSendLeaveGroupRequest` sends for
5233    /// `LEAVE_GROUP`, and for a dynamic member with `DEFAULT`. The member is
5234    /// named by its member id and the reason only.
5235    #[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    /// Kafka's `onJoinPrepare` gives up the partitions of a member that the
5288    /// coordinator no longer knows, and the `JoinGroup` that follows owns
5289    /// nothing. Here a heartbeat during the pre-join auto commit answers
5290    /// `UNKNOWN_MEMBER_ID`.
5291    #[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        // The pre-join auto commit gets no answer, so the task heartbeats while
5306        // it waits.
5307        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    /// Kafka's `close(CloseOptions.timeout(d))` bounds the synchronous auto
5344    /// commit by `d`, and still sends `LeaveGroup` (`maybeLeaveGroup` transmits
5345    /// it before `awaitPendingRequests` checks the timer).
5346    #[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            // Another commit holds the commit lock, so the auto commit of
5357            // `close` waits.
5358            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    /// Kafka's `ConsumerCoordinator.close` waits for the pending asynchronous
5380    /// commits and runs their callbacks before the consumer leaves the group.
5381    #[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        // Another commit holds the commit lock for a while, so the
5387        // asynchronous commit is still pending when `close` starts.
5388        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    /// How a subscription case changes the subscription of a started consumer.
5410    #[derive(Clone, Copy, Debug)]
5411    enum Change {
5412        Subscribe(&'static str),
5413        Unsubscribe,
5414        UnsubscribeThenSubscribe,
5415        PatternWithoutMatch,
5416    }
5417
5418    /// What the coordinator and the application saw in a subscription case:
5419    /// the topics and reason of each `JoinGroup`, the reason of each
5420    /// `LeaveGroup`, and the errors of the polls.
5421    type SubscriptionOutcome = (
5422        Vec<(Vec<String>, Option<String>)>,
5423        Vec<Option<String>>,
5424        Vec<String>,
5425    );
5426
5427    /// Kafka's `subscribe`, `subscribe(Pattern)` and `unsubscribe` on a
5428    /// consumer in a group (`ConsumerCoordinator.rejoinNeededOrPending`,
5429    /// `ClassicKafkaConsumer.unsubscribe`, `AbstractCoordinator.
5430    /// resetGenerationOnLeaveGroup`).
5431    #[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    /// Kafka's `unsubscribe` clears the assignment before it returns, so
5543    /// `assignment` and a commit see no partition right after it.
5544    #[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        // A coordinator task that is busy, here a stopped one, handles the
5551        // change late. The consumer must not wait for it.
5552        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    /// Kafka's `assign` after `unsubscribe` keeps its partitions, and
5565    /// `enforceRebalance` then fails because the consumer has no group
5566    /// subscription.
5567    #[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        // Let the coordinator task handle the changes.
5578        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    /// Kafka's `enforceRebalance(reason)` makes the next `poll` join the group
5600    /// with the reason, or with `rebalance enforced by user`.
5601    #[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    /// A heartbeat can request a rebalance after the start of `poll` signalled
5640    /// the coordinator task. The eager `poll` that then waits for the join
5641    /// must still start it, or it waits until its timeout.
5642    #[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        // The start of `poll` signals the task.
5648        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    /// Kafka's heartbeat thread sends heartbeats whatever the rate of `poll`.
5671    /// An application that polls without a pause keeps the member in the
5672    /// group.
5673    #[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        // An application thread that polls without a pause.
5683        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    /// Kafka's `maybeLeaveGroup` sends the `LeaveGroup` of a poll timeout
5703    /// before the next `poll` can start the join. The coordinator never sees
5704    /// the new member before the old one leaves.
5705    #[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        // The application polls again as soon as the member leaves the group.
5713        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    /// Kafka's `ConsumerCoordinator.metadata` sends one protocol per assignor.
5740    /// An eager member revokes its partitions before the join, so its
5741    /// subscriptions own nothing, and `sticky` sends the last assignment in its
5742    /// user data. A cooperative member sends its owned partitions.
5743    #[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    /// Kafka's `JoinGroup` carries `AbstractCoordinator.rejoinReason`
5826    /// (KIP-800): "group is already rebalancing" after a heartbeat
5827    /// `REBALANCE_IN_PROGRESS`, and "encountered <error> from HEARTBEAT
5828    /// response" after a heartbeat error that resets the member.
5829    #[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    /// One listener callback, or the result of a commit inside a callback.
5898    #[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        /// The partitions that `poll` did not fetch while the assign callback
5905        /// ran.
5906        AwaitingCallback(Vec<(String, i32)>),
5907        /// A `poll` returned this error.
5908        PollError(String),
5909        /// The position of partition 0 after the first `poll`.
5910        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    /// The group event of a listener case.
5986    #[derive(Clone, Copy, Debug)]
5987    enum Event {
5988        /// A heartbeat answers this error code.
5989        Heartbeat(i16),
5990        /// The application closes the consumer.
5991        Close,
5992        /// The application subscribes to this topic.
5993        Subscribe(&'static str),
5994    }
5995
5996    struct Case {
5997        assignor: Assignor,
5998        owned: Vec<(String, i32)>,
5999        /// The assignments of the `SyncGroup` responses.
6000        assignments: Vec<Vec<(String, i32)>>,
6001        event: Event,
6002        commit_on_revoke: bool,
6003        fail_revoke: bool,
6004        seek_on_assign: Option<i64>,
6005        /// The number of calls after which the case stops polling.
6006        calls: usize,
6007    }
6008
6009    /// The listener calls of a case, and the generation of each
6010    /// `OffsetCommit`.
6011    #[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        // The first poll runs the assign callback of the build.
6050        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                // Polls after the last expected call show any extra call.
6081                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    /// Poll once, and record an error of the `poll`.
6119    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    /// A cancelled `poll` keeps the listener, and the callback that it did not
6129    /// finish runs again in the next `poll`. Kafka runs each callback to its
6130    /// end on the application thread.
6131    #[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            // The timeout cancels the `poll` that runs the slow callback.
6169            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    /// Kafka's heartbeat thread leaves the group when the poll timer expires,
6197    /// also while a callback runs inside `poll` (`AbstractCoordinator.
6198    /// HeartbeatThread.run`, `handlePollTimeoutExpiry`).
6199    #[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        // Poll until the slow assign callback of the join has run.
6230        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    /// A listener case without a commit, an error or a seek in a callback.
6249    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    /// Kafka's listener order when a callback commits or fails, when the
6401    /// subscription changes, and when a callback seeks.
6402    #[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}