Skip to main content

barnabas_client/
consumer.rs

1//! The assign-only consumer.
2//!
3//! # There is no consumer group, and that is a scope decision
4//!
5//! No `subscribe`, no JoinGroup/SyncGroup, no heartbeats, no rebalance, no
6//! offset commit. **You choose the partitions** — see [`Consumer::assign`] and the
7//! builder's `assign_all` — and you store the offsets.
8//!
9//! That suits a system whose own control plane places partitions and whose
10//! checkpoints hold offsets, because a group would be a second authority for
11//! both. It suits most other programs badly: if you want partitions
12//! redistributed when an instance dies, this client cannot do it yet, and
13//! `rdkafka` or the Java client can. Groups are planned — see
14//! `docs/completing-the-client.md` — as a layer over this one, not a replacement for
15//! it.
16//!
17//! # One fetch per broker, not per partition
18//!
19//! A consumer holds any number of `(topic, partition)` assignments and fetches
20//! them **together**: partitions are grouped by the broker that leads them, and
21//! each broker gets one `Fetch` carrying all of them.
22//!
23//! This is the read-side twin of the producer's batching, and it matters more
24//! for a per-core client than for a threaded one. A core that owns thirty-two
25//! partitions was previously thirty-two connections and thirty-two round trips
26//! per poll; it is now one connection per broker and one request. That is also
27//! what makes the connection count affordable — the thing `PERF.md` and the
28//! design doc both flagged as the cost of being per-core.
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::time::Duration;
32
33use barnabas_core::consumer::{self, AbortedTransaction, Fetched};
34use barnabas_core::records::LeanBatch;
35use barnabas_core::{Disposition, ErrorCode, IsolationLevel};
36use bytes::Bytes;
37use kafka_protocol::messages::{
38    fetch_request::{FetchPartition, FetchTopic},
39    list_offsets_request::{ListOffsetsPartition, ListOffsetsTopic},
40    ApiKey, BrokerId, FetchRequest, FetchResponse, ListOffsetsRequest, ListOffsetsResponse,
41    TopicName,
42};
43use kafka_protocol::protocol::StrBytes;
44use kafka_protocol::records::{Record, RecordBatchDecoder};
45
46use crate::cluster::Cluster;
47use crate::{check, Error, Result, Transport};
48
49/// Timestamps `ListOffsets` understands.
50pub const EARLIEST: i64 = -2;
51pub const LATEST: i64 = -1;
52
53/// How many times a request is retried when the broker says leadership moved.
54const MAX_LEADER_RETRIES: usize = 5;
55
56/// How long to wait before asking again after a leadership answer that said
57/// "not yet".
58///
59/// Refreshing metadata and retrying *immediately* asks the same question of the
60/// same not-yet-propagated cluster state, so five attempts cost five round
61/// trips and learn nothing. Assigning a consumer to a topic created moments ago
62/// failed for exactly this reason.
63const LEADER_BACKOFF: Duration = Duration::from_millis(100);
64
65/// The broker forgot our fetch session, or our epoch is stale. Both mean
66/// "start again with a full fetch" rather than "fail".
67const FETCH_SESSION_ID_NOT_FOUND: i16 = 70;
68const INVALID_FETCH_SESSION_EPOCH: i16 = 71;
69
70/// Per-broker fetch session state (KIP-227).
71///
72/// **An incremental fetch names only what changed.** A full fetch restates
73/// every partition's offset on every poll, which for a core owning many
74/// partitions is most of the request — and the broker rebuilds its view each
75/// time. With a session, the broker remembers the partition set and the client
76/// sends only the partitions whose position moved.
77///
78/// Idle partitions are the case this exists for: a consumer holding thirty-two
79/// partitions where two are busy sends two partitions per fetch instead of
80/// thirty-two.
81#[derive(Debug, Default, Clone)]
82struct Session {
83    /// 0 until the broker assigns one.
84    id: i32,
85    /// 0 opens a full fetch; each subsequent request increments.
86    epoch: i32,
87    /// What the broker believes our offsets are, so a request can send the
88    /// difference.
89    known: BTreeMap<(String, i32), i64>,
90}
91
92impl Session {
93    /// Forget everything and ask for a full fetch next time.
94    fn reset(&mut self) {
95        self.id = 0;
96        self.epoch = 0;
97        self.known.clear();
98    }
99}
100
101/// What one partition yielded.
102#[derive(Debug)]
103pub struct ConsumerRecords {
104    pub topic: String,
105    pub partition: i32,
106    /// The batches as [`barnabas_core::records`] read them.
107    pub batches: Vec<LeanBatch>,
108    /// Records from a batch the lean reader handed back — only a pre-magic-2
109    /// batch does — decoded the ordinary way. Kept separate rather than
110    /// converted, because building a [`LeanBatch`] from decoded records would
111    /// mean re-serialising them.
112    pub fallback: Vec<Record>,
113}
114
115impl ConsumerRecords {
116    /// Every record, whichever path decoded it.
117    ///
118    /// **This is what callers should use.** Records live in batches because
119    /// that is how the format stores them and how the filtering works, but a
120    /// caller almost never cares which batch a record came from — and having to
121    /// nest two loops, plus handle the fallback, would be a bad trade for the
122    /// speed it buys.
123    pub fn iter(&self) -> impl Iterator<Item = RecordRef<'_>> {
124        self.batches
125            .iter()
126            .flat_map(|batch| {
127                batch
128                    .records
129                    .iter()
130                    .map(move |record| RecordRef::Lean { batch, record })
131            })
132            .chain(self.fallback.iter().map(RecordRef::Full))
133    }
134
135    /// How many records this partition yielded.
136    #[must_use]
137    pub fn len(&self) -> usize {
138        self.batches.iter().map(|b| b.records.len()).sum::<usize>() + self.fallback.len()
139    }
140
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        self.len() == 0
144    }
145}
146
147/// Told when the group gives this consumer partitions, and before it takes
148/// them away.
149///
150/// Kafka's `ConsumerRebalanceListener`, with one difference worth knowing:
151/// **these are synchronous**. This client spawns nothing and holds `!Send`
152/// state, so an async callback would need a boxed future and a runtime to
153/// drive it — and the useful work here (dropping per-partition state, noting
154/// what changed) does not need one. A caller with async cleanup should record
155/// what happened and do it after `poll` returns.
156///
157/// `on_revoked` runs **before** the partitions are given up, so the positions
158/// it is handed are the ones about to be lost. It cannot commit them: by the
159/// time a rebalance is visible the generation that would authorise a commit is
160/// already gone, which is what makes auto-commit at-least-once.
161pub trait RebalanceListener {
162    fn on_revoked(&mut self, partitions: &[barnabas_core::group::TopicPartition]);
163    fn on_assigned(&mut self, partitions: &[barnabas_core::group::TopicPartition]);
164}
165
166/// One record, without a record having been built for it.
167///
168/// A key, value or header list is materialised when asked for, not at decode
169/// time — which is the point: every slice of a batch buffer increments the same
170/// atomic refcount, so a caller that skips a record should not pay for it.
171#[derive(Debug, Clone, Copy)]
172pub enum RecordRef<'a> {
173    Lean {
174        batch: &'a LeanBatch,
175        record: &'a barnabas_core::records::LeanRecord,
176    },
177    /// From a batch the lean reader handed back.
178    Full(&'a Record),
179}
180
181impl RecordRef<'_> {
182    #[must_use]
183    pub fn offset(&self) -> i64 {
184        match self {
185            Self::Lean { record, .. } => record.offset,
186            Self::Full(record) => record.offset,
187        }
188    }
189
190    #[must_use]
191    pub fn timestamp(&self) -> i64 {
192        match self {
193            Self::Lean { record, .. } => record.timestamp,
194            Self::Full(record) => record.timestamp,
195        }
196    }
197
198    #[must_use]
199    pub fn key(&self) -> Option<Bytes> {
200        match self {
201            Self::Lean { batch, record } => batch.key(record),
202            Self::Full(record) => record.key.clone(),
203        }
204    }
205
206    #[must_use]
207    pub fn value(&self) -> Option<Bytes> {
208        match self {
209            Self::Lean { batch, record } => batch.value(record),
210            Self::Full(record) => record.value.clone(),
211        }
212    }
213
214    /// # Errors
215    /// If the header block is malformed.
216    pub fn headers(&self) -> Result<Vec<(Bytes, Option<Bytes>)>> {
217        match self {
218            Self::Lean { batch, record } => Ok(batch.headers(record)?),
219            Self::Full(record) => Ok(record
220                .headers
221                .iter()
222                .map(|(k, v)| (Bytes::copy_from_slice(k.as_str().as_bytes()), v.clone()))
223                .collect()),
224        }
225    }
226}
227
228/// An assign-only consumer over any number of partitions.
229///
230/// Assignment is the caller's: no group protocol, no rebalance, no offset
231/// commit. Where the positions live is also the caller's problem, which is what
232/// makes this usable from a system that checkpoints offsets itself.
233pub struct Consumer<T: Transport> {
234    cluster: Cluster<T>,
235    /// `(topic, partition)` → the offset the next fetch asks for.
236    positions: BTreeMap<(String, i32), i64>,
237    isolation: IsolationLevel,
238    max_wait: Duration,
239    /// Per **partition** budget.
240    max_bytes: i32,
241    /// Per **response** budget, across every partition in the request.
242    ///
243    /// These were the same number, and that was the whole consume bottleneck.
244    /// One `Fetch` per broker carries many partitions, so a single 10 MiB cap
245    /// on the response is 10 MiB shared between them — while a client that
246    /// fetches each partition separately gets 10 MiB *each*. Measured, the
247    /// per-partition shape was nearly three times faster, which had nothing to
248    /// do with connections or decoding and everything to do with this.
249    max_response_bytes: i32,
250    /// One session per broker address.
251    sessions: BTreeMap<String, Session>,
252    /// Whether to use incremental fetch at all. On by default; a caller with a
253    /// broker that mishandles sessions can turn it off without changing code.
254    incremental: bool,
255    /// A fetch already in flight, waiting to be collected. See
256    /// [`Self::poll`].
257    outstanding: Option<Outstanding>,
258    /// Whether to keep a fetch permanently in flight. On by default.
259    prefetch: bool,
260    /// The group this consumer belongs to, if it subscribed rather than
261    /// assigned. See [`Self::subscribe`].
262    group: Option<crate::group::ClassicProtocol>,
263    /// Where to start a partition the group has no committed offset for.
264    reset: i64,
265    /// Commit positions periodically without being asked.
266    auto_commit: Option<Duration>,
267    /// When the last automatic commit happened.
268    last_auto_commit: Option<std::time::Instant>,
269    /// Told when partitions arrive and before they are taken away.
270    listener: Option<Box<dyn RebalanceListener>>,
271    /// `(session, rebalance)`, applied when a group is joined.
272    group_timeouts: Option<(Duration, Duration)>,
273    /// Partitions held but not fetched. Separate from `positions` because a
274    /// paused partition is still **assigned**: it keeps its offset, it counts
275    /// against the group, and resuming it must not re-resolve where it was.
276    paused: BTreeSet<(String, i32)>,
277    /// Topics seen to have grown partitions, `topic -> (before, after)`,
278    /// waiting to be reported by [`Consumer::take_expansions`].
279    expansions: BTreeMap<String, (i32, i32)>,
280    /// Bumped whenever the assignment or a position changes for a reason other
281    /// than consuming records. An outstanding fetch from an older generation
282    /// asked a question that is no longer the one being asked.
283    generation: u64,
284}
285
286/// One broker's address, the partitions it was asked about, and the request.
287type Planned = (String, Vec<(String, i32)>, FetchRequest);
288
289/// A fetch that has been sent and not yet collected.
290struct Outstanding {
291    /// The partitions each broker was asked about, in request order.
292    groups: Vec<(String, Vec<(String, i32)>)>,
293    generation: u64,
294}
295
296impl<T: Transport> Consumer<T> {
297    /// A staged builder, which is the guided way in — see
298    /// [`builder`](crate::builder).
299    pub fn builder(transport: T) -> crate::builder::ConsumerBuilder<T> {
300        crate::builder::ConsumerBuilder::new(transport)
301    }
302
303    /// Wrap an already-configured cluster, so the builder can set credentials
304    /// before the first request.
305    pub(crate) fn from_cluster(cluster: Cluster<T>, isolation: IsolationLevel) -> Self {
306        Self {
307            cluster,
308            positions: BTreeMap::new(),
309            paused: BTreeSet::new(),
310            expansions: BTreeMap::new(),
311            isolation,
312            max_wait: Duration::from_millis(500),
313            max_bytes: 10 * 1024 * 1024,
314            max_response_bytes: 64 * 1024 * 1024,
315            sessions: BTreeMap::new(),
316            incremental: true,
317            outstanding: None,
318            prefetch: true,
319            group: None,
320            reset: EARLIEST,
321            auto_commit: None,
322            last_auto_commit: None,
323            listener: None,
324            group_timeouts: None,
325            generation: 0,
326        }
327    }
328
329    /// Connect with no assignments. Add them with [`Self::assign`].
330    ///
331    /// # Errors
332    /// If no bootstrap address answers.
333    pub async fn new(
334        transport: T,
335        bootstrap: &[String],
336        client_id: &str,
337        isolation: IsolationLevel,
338    ) -> Result<Self> {
339        Ok(Self {
340            cluster: Cluster::connect(transport, bootstrap, client_id).await?,
341            positions: BTreeMap::new(),
342            paused: BTreeSet::new(),
343            expansions: BTreeMap::new(),
344            isolation,
345            max_wait: Duration::from_millis(500),
346            max_bytes: 10 * 1024 * 1024,
347            max_response_bytes: 64 * 1024 * 1024,
348            sessions: BTreeMap::new(),
349            incremental: true,
350            outstanding: None,
351            prefetch: true,
352            group: None,
353            reset: EARLIEST,
354            auto_commit: None,
355            last_auto_commit: None,
356            listener: None,
357            group_timeouts: None,
358            generation: 0,
359        })
360    }
361
362    /// Connect and assign one partition — the common case, and what the
363    /// single-partition callers use.
364    ///
365    /// # Errors
366    /// As [`Self::new`], plus a missing topic or partition.
367    pub async fn for_partition(
368        transport: T,
369        bootstrap: &[String],
370        client_id: &str,
371        topic: &str,
372        partition: i32,
373        offset: i64,
374        isolation: IsolationLevel,
375    ) -> Result<Self> {
376        let mut me = Self::new(transport, bootstrap, client_id, isolation).await?;
377        me.assign(topic, partition, offset).await?;
378        Ok(me)
379    }
380
381    /// Assign another partition, starting at `offset`.
382    ///
383    /// `offset` may be [`EARLIEST`], [`LATEST`], or an absolute offset; the
384    /// first two are resolved with `ListOffsets` before the first fetch.
385    ///
386    /// # Errors
387    /// If the topic does not exist, or the broker answers with an error code.
388    pub async fn assign(&mut self, topic: &str, partition: i32, offset: i64) -> Result<()> {
389        // **Before anything else touches a connection.** A prefetched `Fetch`
390        // may be sitting unread on one of them, and a `Metadata` sent past it
391        // would be answered by that fetch — responses come back in order, so
392        // the decode would be of the wrong message for the wrong request.
393        self.discard_outstanding().await;
394
395        // Up front so a missing topic fails here rather than as a fetch loop
396        // that never returns anything.
397        self.cluster.refresh_metadata(topic).await?;
398        self.positions.insert((topic.to_owned(), partition), offset);
399
400        if offset == EARLIEST || offset == LATEST {
401            let resolved = self.list_offset(topic, partition, offset).await?;
402            self.positions
403                .insert((topic.to_owned(), partition), resolved);
404        }
405        // A new assignment changes the set the broker remembers.
406        for session in self.sessions.values_mut() {
407            session.reset();
408        }
409        self.generation += 1;
410        Ok(())
411    }
412
413    /// How many partitions `topic` has, from the broker's metadata.
414    ///
415    /// This client never chooses partitions for you — there is no consumer
416    /// group, so nothing assigns them behind your back — but it does have to
417    /// ask the broker where they live, and the count comes back with that.
418    ///
419    /// # Errors
420    /// If metadata cannot be refreshed, or the topic does not exist.
421    pub async fn partition_count(&mut self, topic: &str) -> Result<i32> {
422        self.cluster.partition_count(topic).await
423    }
424
425    /// Join `group_id` and let the group decide which partitions this consumer
426    /// reads.
427    ///
428    /// **This is the shape most programs want**, and the opposite of
429    /// [`Self::assign`]: partitions arrive from the group's leader and change
430    /// when membership does. [`Self::poll`] drives the membership as a side
431    /// effect, so a caller that keeps polling keeps its place in the group.
432    ///
433    /// `reset` decides where a partition starts when the group has never
434    /// committed an offset for it — Kafka's `auto.offset.reset`.
435    ///
436    /// # Errors
437    /// If the coordinator cannot be found.
438    pub async fn subscribe(
439        &mut self,
440        group_id: &str,
441        topics: Vec<String>,
442        assignor: Box<dyn barnabas_core::group::Assignor>,
443        reset: i64,
444    ) -> Result<()> {
445        self.discard_outstanding().await;
446        self.positions.clear();
447        self.paused.clear();
448        self.reset = reset;
449        let mut protocol =
450            crate::group::ClassicProtocol::new(group_id.to_owned(), topics, assignor);
451        if let Some((session, rebalance)) = self.group_timeouts {
452            protocol.set_session_timeout(i32::try_from(session.as_millis()).unwrap_or(i32::MAX));
453            protocol
454                .set_rebalance_timeout(i32::try_from(rebalance.as_millis()).unwrap_or(i32::MAX));
455        }
456        self.group = Some(protocol);
457        self.generation += 1;
458        Ok(())
459    }
460
461    /// Commit positions on a timer, without the caller asking.
462    ///
463    /// Kafka's `enable.auto.commit` with `auto.commit.interval.ms`, and the same
464    /// guarantee: **at least once**. The commit happens at the *start* of a
465    /// [`Self::poll`], so what is committed is where the previous poll's records
466    /// ended — records handed to the caller and not yet committed are
467    /// re-delivered after a crash. A caller that needs a record committed only
468    /// once it is durably handled should commit itself, with
469    /// [`Self::commit`], after it has.
470    ///
471    /// Off by default, because "at least once, silently" is a worse surprise
472    /// than having to ask.
473    pub fn set_auto_commit(&mut self, interval: Option<Duration>) {
474        self.auto_commit = interval;
475        self.last_auto_commit = None;
476    }
477
478    /// Be told when partitions arrive and before they are taken away.
479    ///
480    /// The Kafka equivalent of `ConsumerRebalanceListener`. See
481    /// [`RebalanceListener`] for why these are synchronous.
482    pub fn set_rebalance_listener(&mut self, listener: Box<dyn RebalanceListener>) {
483        self.listener = Some(listener);
484    }
485
486    /// How long the coordinator waits for a heartbeat before removing this
487    /// member, and how long it waits for the group to rejoin a rebalance.
488    ///
489    /// Kafka's `session.timeout.ms` and `max.poll.interval.ms`. The rebalance
490    /// timeout also bounds how long a `JoinGroup` may be held: the coordinator
491    /// keeps it until every member has rejoined, so a small value fails a stuck
492    /// rebalance quickly and a large one waits patiently for slow members.
493    ///
494    /// Must be set before [`Self::subscribe`]; changing it afterwards would
495    /// disagree with what the group was told.
496    pub fn set_group_timeouts(&mut self, session: Duration, rebalance: Duration) {
497        self.group_timeouts = Some((session, rebalance));
498    }
499
500    /// Tell the coordinator this member is alive, without polling.
501    ///
502    /// **This client spawns nothing, so heartbeats ride on [`Self::poll`].**
503    /// That is fine for a caller that polls in a loop, and wrong for one that
504    /// spends longer than `session.timeout.ms` handling a batch: the
505    /// coordinator removes a member it has not heard from, its partitions are
506    /// given to someone else, and the slow member's next commit is rejected.
507    ///
508    /// Java hides this with a background heartbeat thread and a separate
509    /// `max.poll.interval.ms`. The equivalent here is to call this from your
510    /// own task while you work — it needs only `&mut Consumer`, so a caller
511    /// that processes on the same executor can interleave it.
512    ///
513    /// Returns whether the assignment changed, which is the same signal
514    /// [`Self::poll`] acts on: `true` means partitions were revoked or granted
515    /// and any in-flight work on the old ones should stop.
516    ///
517    /// # Errors
518    /// If the coordinator cannot be reached. Not being in a group is not an
519    /// error — it simply does nothing.
520    pub async fn heartbeat(&mut self) -> Result<bool> {
521        if self.group.is_none() {
522            return Ok(false);
523        }
524        self.advance_group().await
525    }
526
527    /// Leave the group, giving up every partition.
528    ///
529    /// **Worth calling before dropping a consumer.** Without it the coordinator
530    /// keeps this member until its session times out — tens of seconds during
531    /// which its partitions are read by nobody, and any other member joining
532    /// waits out the same delay.
533    ///
534    /// # Errors
535    /// If the coordinator cannot be reached. The member is forgotten locally
536    /// either way: the coordinator drops it at the session timeout regardless.
537    pub async fn unsubscribe(&mut self) -> Result<()> {
538        self.discard_outstanding().await;
539        self.positions.clear();
540        self.generation += 1;
541        let Some(mut group) = self.group.take() else {
542            return Ok(());
543        };
544        crate::group::GroupProtocol::leave(&mut group, &mut self.cluster).await
545    }
546
547    /// Commit the position of every partition this consumer holds.
548    ///
549    /// The position is **the next offset to read**, which is what Kafka stores
550    /// and what a restart resumes from.
551    ///
552    /// # Errors
553    /// If this consumer is not in a group, or the group is mid-rebalance — a
554    /// commit then would write an offset for a partition that may already
555    /// belong to another member.
556    pub async fn commit(&mut self) -> Result<()> {
557        // **Before anything else touches a connection.** A prefetched `Fetch`
558        // is sitting unread on one of them, and the coordinator for this group
559        // may be that same broker — responses come back in order, so the commit
560        // would decode the fetch's answer. `add` and `list_offset` drain for
561        // the same reason; this one was missed, and the symptom was a commit
562        // failing with "decode Fetch v12 response".
563        self.discard_outstanding().await;
564
565        let offsets: BTreeMap<barnabas_core::group::TopicPartition, i64> = self
566            .positions
567            .iter()
568            .map(|((topic, partition), offset)| {
569                (
570                    barnabas_core::group::TopicPartition::new(topic.clone(), *partition),
571                    *offset,
572                )
573            })
574            .collect();
575
576        let Some(group) = self.group.as_mut() else {
577            return Err(Error::Missing("a group to commit to"));
578        };
579        crate::group::GroupProtocol::commit(group, &mut self.cluster, &offsets).await
580    }
581
582    /// How long a topic's metadata may go unrefreshed.
583    ///
584    /// Five minutes by default, matching `metadata.max.age.ms`. It bounds how
585    /// long a partition expansion goes unnoticed — see
586    /// [`Self::take_expansions`].
587    pub fn set_metadata_max_age(&mut self, age: Duration) {
588        self.cluster.set_metadata_max_age(age);
589    }
590
591    /// Topics that have grown partitions since this was last called, as
592    /// `(topic, before, after)`. Drains what it returns.
593    ///
594    /// **A manual assignment is never extended for you.** Java does not do it
595    /// either, and it should not: the whole point of [`Self::assign`] is that
596    /// the caller decides what it reads. But a caller who is never *told* has
597    /// no way to decide, and the failure is silent — records land on partitions
598    /// nobody reads, no error is raised, and [`Self::lag`] looks perfect
599    /// because it only covers what is assigned.
600    ///
601    /// A **subscribed** consumer never reports here: an expansion makes it
602    /// rejoin its group instead, and the leader assigns the new partitions.
603    pub fn take_expansions(&mut self) -> Vec<(String, i32, i32)> {
604        std::mem::take(&mut self.expansions)
605            .into_iter()
606            .map(|(topic, (before, after))| (topic, before, after))
607            .collect()
608    }
609
610    /// Re-read stale metadata for the topics this consumer cares about, and
611    /// act on any that grew.
612    ///
613    /// Cheap between refreshes: [`Cluster::is_metadata_stale`] is a map lookup
614    /// and a subtraction, so this runs on every poll and does nothing on almost
615    /// all of them.
616    async fn check_for_expansion(&mut self) -> Result<()> {
617        let mut topics: Vec<String> = match self.group.as_ref() {
618            // A subscribed consumer watches what it *subscribed to*, not what
619            // it was assigned: a member holding no partitions of a topic is
620            // exactly the member that must notice the topic growing.
621            Some(group) => crate::group::GroupProtocol::<T>::topics(group),
622            None => self
623                .positions
624                .keys()
625                .map(|(topic, _)| topic.clone())
626                .collect(),
627        };
628        topics.sort();
629        topics.dedup();
630        topics.retain(|topic| self.cluster.is_metadata_stale(topic));
631        if topics.is_empty() {
632            return Ok(());
633        }
634
635        // **A prefetch is sitting unread on one of these connections**, and a
636        // `Metadata` request would decode its answer. Every other caller that
637        // touches a connection does this — `assign`, `commit`, `list_offset` —
638        // and it is done *here*, after the staleness check, so the ordinary
639        // poll keeps its prefetch and only the refresh pays.
640        //
641        // Without it the refresh returned `ConnectionBusy`, which this function
642        // swallows, so expansion was never detected and nothing said why.
643        self.discard_outstanding().await;
644
645        let mut grew = false;
646        for topic in topics {
647            // A refresh that fails is not fatal here — nothing has broken yet,
648            // and the next poll asks again. Failing the poll would turn a
649            // background check into an outage.
650            if let Ok(Some((before, after))) = self.cluster.refresh_if_stale(&topic).await {
651                if self.group.is_some() {
652                    grew = true;
653                } else {
654                    self.expansions.insert(topic, (before, after));
655                }
656            }
657        }
658        if grew {
659            if let Some(group) = self.group.as_mut() {
660                crate::group::GroupProtocol::<T>::request_rejoin(group);
661            }
662        }
663        Ok(())
664    }
665
666    /// Where this consumer would commit to, per partition: **the next offset
667    /// to read**, not the last one read.
668    ///
669    /// For exactly-once with a group, this is the map to hand
670    /// [`Producer::send_offsets_to_transaction`](crate::Producer::send_offsets_to_transaction)
671    /// together with [`Self::group_metadata`]. Read it *after* the records it
672    /// covers have been produced, or the transaction commits offsets for output
673    /// it did not write.
674    #[must_use]
675    pub fn positions(&self) -> BTreeMap<barnabas_core::group::TopicPartition, i64> {
676        self.positions
677            .iter()
678            .map(|((topic, partition), offset)| {
679                (
680                    barnabas_core::group::TopicPartition::new(topic.clone(), *partition),
681                    *offset,
682                )
683            })
684            .collect()
685    }
686
687    /// This member's identity and fencing token, or `None` if it is not in a
688    /// group or not currently stable.
689    ///
690    /// Hand it to a transactional producer so the coordinator can reject
691    /// offsets from a member that has already been replaced. **Fetch it fresh
692    /// per transaction** — a rebalance in between invalidates it, and that is
693    /// the whole point of it.
694    #[must_use]
695    pub fn group_metadata(&self) -> Option<crate::group::GroupMetadata> {
696        self.group
697            .as_ref()
698            .and_then(crate::group::GroupProtocol::<T>::group_metadata)
699    }
700
701    /// Commit if auto-commit is on and its interval has elapsed.
702    async fn maybe_auto_commit(&mut self) -> Result<()> {
703        let Some(interval) = self.auto_commit else {
704            return Ok(());
705        };
706        let due = self
707            .last_auto_commit
708            .is_none_or(|last| last.elapsed() >= interval);
709        if !due || self.positions.is_empty() {
710            return Ok(());
711        }
712        // A commit refused because the group is mid-rebalance is not an error
713        // for the caller: the partitions are about to belong to someone else,
714        // and the offsets go with them.
715        match self.commit().await {
716            Ok(())
717            | Err(Error::Broker {
718                op: "OffsetCommit", ..
719            }) => {}
720            Err(e) => return Err(e),
721        }
722        self.last_auto_commit = Some(std::time::Instant::now());
723        Ok(())
724    }
725
726    /// Drive group membership to a settled state, if this consumer subscribed.
727    ///
728    /// **Loops until the member is stable**, rather than taking one protocol
729    /// step per call. A rejoin is `JoinGroup` then `SyncGroup`, sometimes twice
730    /// over — and a coordinator waits only `rebalance.timeout.ms` for every
731    /// member to come back. A client that advanced one step per poll spent a
732    /// whole poll cycle on each, so the rebalance completed without it, and its
733    /// next heartbeat returned `UNKNOWN_MEMBER_ID`: dropped, rejoined as a new
734    /// member, and the group never settled. Java's `joinGroupIfNeeded` loops
735    /// for the same reason.
736    ///
737    /// Returns whether the assignment changed.
738    async fn advance_group(&mut self) -> Result<bool> {
739        // Bounded so a group that genuinely cannot settle returns to the caller
740        // instead of spinning here: a poll that never comes back is worse than
741        // one that reports no records.
742        const MAX_STEPS: usize = 20;
743
744        let mut changed = false;
745        for _ in 0..MAX_STEPS {
746            let Some(mut group) = self.group.take() else {
747                return Ok(changed);
748            };
749            let outcome = crate::group::GroupProtocol::advance(&mut group, &mut self.cluster).await;
750
751            let settled = match &outcome {
752                Ok(crate::group::Membership::Assigned(partitions)) => {
753                    let wanted: BTreeMap<(String, i32), ()> = partitions
754                        .iter()
755                        .map(|tp| ((tp.topic.clone(), tp.partition), ()))
756                        .collect();
757                    let same = wanted.len() == self.positions.len()
758                        && wanted.keys().all(|k| self.positions.contains_key(k));
759                    if !same {
760                        let committed = crate::group::GroupProtocol::committed(
761                            &mut group,
762                            &mut self.cluster,
763                            partitions,
764                        )
765                        .await?;
766                        self.positions.clear();
767                        for tp in partitions {
768                            let start = committed.get(tp).copied().unwrap_or(self.reset);
769                            self.positions
770                                .insert((tp.topic.clone(), tp.partition), start);
771                        }
772                        if let Some(listener) = self.listener.as_mut() {
773                            listener.on_assigned(partitions);
774                        }
775                        changed = true;
776                    }
777                    true
778                }
779                Ok(crate::group::Membership::Revoked(lost)) => {
780                    // Only what was actually lost: the eager protocol reports
781                    // everything, the cooperative one only what moved.
782                    if let Some(listener) = self.listener.as_mut() {
783                        listener.on_revoked(lost);
784                    }
785                    for tp in lost {
786                        self.positions.remove(&(tp.topic.clone(), tp.partition));
787                        // **A pause does not survive losing the partition.**
788                        // Another member is about to read it, and if this one
789                        // is given it back the pause would be invisible state
790                        // that silently stops consumption.
791                        self.paused.remove(&(tp.topic.clone(), tp.partition));
792                    }
793                    changed |= !lost.is_empty();
794                    false
795                }
796                Ok(crate::group::Membership::InProgress) | Err(_) => false,
797            };
798
799            self.group = Some(group);
800            outcome?;
801            if settled {
802                break;
803            }
804        }
805
806        if changed {
807            self.generation += 1;
808            for session in self.sessions.values_mut() {
809                session.reset();
810            }
811            let unresolved: Vec<(String, i32, i64)> = self
812                .positions
813                .iter()
814                .filter(|(_, offset)| **offset == EARLIEST || **offset == LATEST)
815                .map(|((topic, partition), offset)| (topic.clone(), *partition, *offset))
816                .collect();
817            for (topic, partition, offset) in unresolved {
818                let resolved = self.list_offset(&topic, partition, offset).await?;
819                self.positions.insert((topic, partition), resolved);
820            }
821        }
822        Ok(changed)
823    }
824
825    /// Stop fetching a partition.
826    ///
827    /// Resets the fetch sessions: the broker's remembered partition set no
828    /// longer matches ours, and correcting it with `forgotten_topics_data` is
829    /// more machinery than a fresh full fetch costs.
830    pub fn remove(&mut self, topic: &str, partition: i32) {
831        self.positions.remove(&(topic.to_owned(), partition));
832        self.paused.remove(&(topic.to_owned(), partition));
833        self.generation += 1;
834        for session in self.sessions.values_mut() {
835            session.reset();
836        }
837    }
838
839    /// Stop fetching these partitions without giving them up.
840    ///
841    /// The partitions stay assigned and keep their positions — this is
842    /// backpressure, not a revocation, and a paused consumer must keep polling
843    /// or the group will decide it is gone.
844    ///
845    /// Resets the fetch sessions for the same reason [`Self::remove`] does: the
846    /// broker's remembered set no longer matches ours.
847    pub fn pause(&mut self, partitions: &[barnabas_core::group::TopicPartition]) {
848        for tp in partitions {
849            self.paused.insert((tp.topic.clone(), tp.partition));
850        }
851        self.on_fetch_set_changed();
852    }
853
854    /// Fetch these partitions again, from wherever they stopped.
855    pub fn resume(&mut self, partitions: &[barnabas_core::group::TopicPartition]) {
856        for tp in partitions {
857            self.paused.remove(&(tp.topic.clone(), tp.partition));
858        }
859        self.on_fetch_set_changed();
860    }
861
862    /// Every partition currently paused, whether or not it is still assigned.
863    pub fn paused(&self) -> impl Iterator<Item = (&str, i32)> {
864        self.paused
865            .iter()
866            .map(|(topic, partition)| (topic.as_str(), *partition))
867    }
868
869    /// Whether one partition is paused.
870    #[must_use]
871    pub fn is_paused(&self, topic: &str, partition: i32) -> bool {
872        self.paused.contains(&(topic.to_owned(), partition))
873    }
874
875    /// A prefetch in flight asked about a set that no longer applies, and the
876    /// broker's session remembers that set too.
877    fn on_fetch_set_changed(&mut self) {
878        self.generation += 1;
879        for session in self.sessions.values_mut() {
880            session.reset();
881        }
882    }
883
884    /// Assigned and not paused: what a fetch may ask about.
885    fn fetchable(&self) -> Vec<(String, i32)> {
886        self.positions
887            .keys()
888            .filter(|key| !self.paused.contains(*key))
889            .cloned()
890            .collect()
891    }
892
893    /// Every partition this consumer holds.
894    pub fn assignments(&self) -> impl Iterator<Item = (&str, i32)> {
895        self.positions
896            .keys()
897            .map(|(topic, partition)| (topic.as_str(), *partition))
898    }
899
900    /// Where the next fetch will start for one partition.
901    #[must_use]
902    pub fn position_of(&self, topic: &str, partition: i32) -> Option<i64> {
903        self.positions.get(&(topic.to_owned(), partition)).copied()
904    }
905
906    /// Where the next fetch will start, for a consumer holding exactly one
907    /// partition.
908    ///
909    /// # Panics
910    /// If the consumer holds anything other than one assignment — with several,
911    /// "the position" is not a question with an answer.
912    #[must_use]
913    pub fn position(&self) -> i64 {
914        assert_eq!(
915            self.positions.len(),
916            1,
917            "position() needs exactly one assignment; use position_of()"
918        );
919        *self.positions.values().next().expect("checked length")
920    }
921
922    /// Seek one partition. The caller owns its offsets, so this is how a
923    /// restored checkpoint is applied.
924    pub fn seek_to(&mut self, topic: &str, partition: i32, offset: i64) {
925        self.positions.insert((topic.to_owned(), partition), offset);
926        self.generation += 1;
927    }
928
929    /// Seek, for a consumer holding exactly one partition.
930    ///
931    /// # Panics
932    /// As [`Self::position`].
933    pub fn seek(&mut self, offset: i64) {
934        assert_eq!(
935            self.positions.len(),
936            1,
937            "seek() needs exactly one assignment; use seek_to()"
938        );
939        let key = self
940            .positions
941            .keys()
942            .next()
943            .expect("checked length")
944            .clone();
945        self.positions.insert(key, offset);
946        self.generation += 1;
947    }
948
949    /// Use incremental fetch sessions (KIP-227). On by default.
950    ///
951    /// Turning this off makes every fetch restate every partition, which is
952    /// what the client did before sessions existed — useful if a broker or
953    /// proxy mishandles them.
954    pub fn set_incremental_fetch(&mut self, incremental: bool) {
955        self.incremental = incremental;
956        if !incremental {
957            self.sessions.clear();
958        }
959        self.generation += 1;
960    }
961
962    /// Keep a fetch permanently in flight. On by default.
963    ///
964    /// **This is what overlaps the network with the caller's work.** Without
965    /// it a fetch is issued only when [`Self::poll`] is called, so every poll
966    /// pays a full round trip before it can return anything; with it the
967    /// request for the next poll goes out as soon as the current one is
968    /// decoded, and the caller's processing happens while the broker is
969    /// already working.
970    ///
971    /// Exactly one fetch per broker is outstanding, never more: the fetch
972    /// session epoch advances per accepted response, so a second in-flight
973    /// request would carry an epoch the broker has not reached.
974    pub fn set_prefetch(&mut self, prefetch: bool) {
975        self.prefetch = prefetch;
976        self.generation += 1;
977    }
978
979    /// How long a fetch waits for data before returning empty.
980    pub fn set_max_wait(&mut self, max_wait: Duration) {
981        self.max_wait = max_wait;
982        self.generation += 1;
983    }
984
985    /// The connections this consumer holds — one per broker it fetches from,
986    /// **not** one per partition.
987    #[must_use]
988    pub fn connection_count(&self) -> usize {
989        self.cluster.connection_count()
990    }
991
992    /// The address the cluster map names as this partition's leader.
993    #[must_use]
994    pub fn metadata_leader(&self, topic: &str, partition: i32) -> Option<String> {
995        self.cluster
996            .metadata()
997            .leader_for(topic, partition)
998            .map(barnabas_core::BrokerAddr::addr)
999    }
1000
1001    /// Resolve a timestamp to an offset for one partition. [`EARLIEST`] and
1002    /// [`LATEST`] are the two a consumer normally wants.
1003    ///
1004    /// # Errors
1005    /// If the broker answers with an error code.
1006    pub async fn list_offset(
1007        &mut self,
1008        topic: &str,
1009        partition: i32,
1010        timestamp: i64,
1011    ) -> Result<i64> {
1012        // Public, so it can be called with a prefetch in flight. See
1013        // [`Self::assign`].
1014        self.discard_outstanding().await;
1015
1016        let mut req_partition = ListOffsetsPartition::default();
1017        req_partition.partition_index = partition;
1018        req_partition.timestamp = timestamp;
1019
1020        let mut req_topic = ListOffsetsTopic::default();
1021        req_topic.name = TopicName(StrBytes::from_string(topic.to_owned()));
1022        req_topic.partitions = vec![req_partition];
1023
1024        let mut req = ListOffsetsRequest::default();
1025        req.replica_id = BrokerId(-1);
1026        req.isolation_level = self.isolation.as_i8();
1027        req.topics = vec![req_topic];
1028
1029        for attempt in 0..=MAX_LEADER_RETRIES {
1030            // A partition mid-election has no leader *yet*. That is a wait, not
1031            // a failure — the producer has always treated it that way, and a
1032            // consumer assigned to a topic created a moment ago hit the other
1033            // behaviour and simply failed.
1034            let addr = match self.cluster.leader_addr(topic, partition).await {
1035                Ok(addr) => addr,
1036                Err(e @ Error::NoLeader { .. }) => {
1037                    if attempt == MAX_LEADER_RETRIES {
1038                        return Err(e);
1039                    }
1040                    T::sleep(LEADER_BACKOFF).await;
1041                    continue;
1042                }
1043                Err(e) => return Err(e),
1044            };
1045            let resp: ListOffsetsResponse = self
1046                .cluster
1047                .call_at(&addr, ApiKey::ListOffsets, 7, &req)
1048                .await?;
1049
1050            let found = resp
1051                .topics
1052                .iter()
1053                .flat_map(|t| t.partitions.iter())
1054                .find(|p| p.partition_index == partition)
1055                .ok_or(Error::Missing("partition"))?;
1056
1057            let code = ErrorCode(found.error_code);
1058            if code.disposition() == Disposition::RefreshMetadata {
1059                self.cluster.invalidate(topic, partition);
1060                if attempt == MAX_LEADER_RETRIES {
1061                    return Err(Error::Broker {
1062                        op: "ListOffsets",
1063                        code: code.0,
1064                        disposition: code.disposition(),
1065                    });
1066                }
1067                self.cluster.refresh_metadata(topic).await?;
1068                T::sleep(LEADER_BACKOFF).await;
1069                continue;
1070            }
1071            check("ListOffsets", found.error_code)?;
1072            return Ok(found.offset);
1073        }
1074        unreachable!("the loop returns on its last attempt")
1075    }
1076
1077    /// `ListOffsets` for many partitions at once: **one request per leader**,
1078    /// not one per partition.
1079    ///
1080    /// Every lookup below is this: `end_offsets` on a 64-partition assignment
1081    /// is one or two round trips rather than 64. Returns `(offset, timestamp)`
1082    /// per partition, and **omits** a partition whose answer is "no such
1083    /// offset" (`-1`), which is what `offsets_for_times` needs to distinguish
1084    /// from offset zero.
1085    async fn list_offsets_many(
1086        &mut self,
1087        want: &[(barnabas_core::group::TopicPartition, i64)],
1088    ) -> Result<BTreeMap<barnabas_core::group::TopicPartition, (i64, i64)>> {
1089        // See [`Self::assign`]: a prefetch is sitting unread on a connection
1090        // this is about to reuse.
1091        self.discard_outstanding().await;
1092
1093        let mut found = BTreeMap::new();
1094        if want.is_empty() {
1095            return Ok(found);
1096        }
1097
1098        let mut remaining: Vec<(barnabas_core::group::TopicPartition, i64)> = want.to_vec();
1099        for attempt in 0..=MAX_LEADER_RETRIES {
1100            // Group by leader afresh each attempt: a retry is usually here
1101            // *because* leadership moved.
1102            let mut by_leader: BTreeMap<String, Vec<(barnabas_core::group::TopicPartition, i64)>> =
1103                BTreeMap::new();
1104            let mut no_leader: Option<Error> = None;
1105            for (tp, timestamp) in &remaining {
1106                match self.cluster.leader_addr(&tp.topic, tp.partition).await {
1107                    Ok(addr) => by_leader
1108                        .entry(addr)
1109                        .or_default()
1110                        .push((tp.clone(), *timestamp)),
1111                    Err(e @ Error::NoLeader { .. }) => no_leader = Some(e),
1112                    Err(e) => return Err(e),
1113                }
1114            }
1115
1116            let mut retry: Vec<(barnabas_core::group::TopicPartition, i64)> = Vec::new();
1117            let mut refresh: Vec<String> = Vec::new();
1118            for (addr, group) in by_leader {
1119                let mut topics: BTreeMap<String, Vec<ListOffsetsPartition>> = BTreeMap::new();
1120                for (tp, timestamp) in &group {
1121                    let mut entry = ListOffsetsPartition::default();
1122                    entry.partition_index = tp.partition;
1123                    entry.timestamp = *timestamp;
1124                    topics.entry(tp.topic.clone()).or_default().push(entry);
1125                }
1126
1127                let mut req = ListOffsetsRequest::default();
1128                req.replica_id = BrokerId(-1);
1129                req.isolation_level = self.isolation.as_i8();
1130                req.topics = topics
1131                    .into_iter()
1132                    .map(|(name, partitions)| {
1133                        let mut topic = ListOffsetsTopic::default();
1134                        topic.name = TopicName(StrBytes::from_string(name));
1135                        topic.partitions = partitions;
1136                        topic
1137                    })
1138                    .collect();
1139
1140                let resp: ListOffsetsResponse = self
1141                    .cluster
1142                    .call_at(&addr, ApiKey::ListOffsets, 7, &req)
1143                    .await?;
1144
1145                for topic in &resp.topics {
1146                    for partition in &topic.partitions {
1147                        let tp = barnabas_core::group::TopicPartition::new(
1148                            topic.name.0.to_string(),
1149                            partition.partition_index,
1150                        );
1151                        let code = ErrorCode(partition.error_code);
1152                        if code.is_ok() {
1153                            // `-1` is "nothing at or after that timestamp", not
1154                            // an error and not offset -1.
1155                            if partition.offset >= 0 {
1156                                found.insert(tp, (partition.offset, partition.timestamp));
1157                            }
1158                            continue;
1159                        }
1160                        if code.disposition() == Disposition::RefreshMetadata {
1161                            self.cluster.invalidate(&tp.topic, tp.partition);
1162                            refresh.push(tp.topic.clone());
1163                            let timestamp = group
1164                                .iter()
1165                                .find(|(w, _)| *w == tp)
1166                                .map_or(LATEST, |(_, t)| *t);
1167                            retry.push((tp, timestamp));
1168                            continue;
1169                        }
1170                        check("ListOffsets", partition.error_code)?;
1171                    }
1172                }
1173            }
1174
1175            // A partition whose leader is mid-election is a wait, not a
1176            // failure — the same rule `list_offset` follows.
1177            if let Some(e) = no_leader {
1178                if retry.is_empty() && attempt == MAX_LEADER_RETRIES {
1179                    return Err(e);
1180                }
1181                for (tp, timestamp) in &remaining {
1182                    if !found.contains_key(tp) && !retry.iter().any(|(r, _)| r == tp) {
1183                        retry.push((tp.clone(), *timestamp));
1184                    }
1185                }
1186            }
1187
1188            if retry.is_empty() {
1189                return Ok(found);
1190            }
1191            if attempt == MAX_LEADER_RETRIES {
1192                return Err(Error::Broker {
1193                    op: "ListOffsets",
1194                    code: ErrorCode::NOT_LEADER_OR_FOLLOWER.0,
1195                    disposition: Disposition::RefreshMetadata,
1196                });
1197            }
1198            refresh.sort();
1199            refresh.dedup();
1200            for topic in refresh {
1201                self.cluster.refresh_metadata(&topic).await?;
1202            }
1203            T::sleep(LEADER_BACKOFF).await;
1204            remaining = retry;
1205        }
1206        unreachable!("the loop returns on its last attempt")
1207    }
1208
1209    /// The offset **after** the last record of each partition — the log end.
1210    ///
1211    /// Under READ_COMMITTED this is the last stable offset, so it does not run
1212    /// ahead of what a committed reader can see, and lag computed from it does
1213    /// not sit permanently at the size of an open transaction.
1214    ///
1215    /// # Errors
1216    /// If no leader answers.
1217    pub async fn end_offsets(
1218        &mut self,
1219        partitions: &[barnabas_core::group::TopicPartition],
1220    ) -> Result<BTreeMap<barnabas_core::group::TopicPartition, i64>> {
1221        let want: Vec<_> = partitions.iter().cloned().map(|tp| (tp, LATEST)).collect();
1222        Ok(self
1223            .list_offsets_many(&want)
1224            .await?
1225            .into_iter()
1226            .map(|(tp, (offset, _))| (tp, offset))
1227            .collect())
1228    }
1229
1230    /// The offset of the oldest record still retained in each partition.
1231    ///
1232    /// Not zero: retention and `DeleteRecords` move it forward, and assuming
1233    /// zero is how a consumer asks for an offset the broker has deleted.
1234    ///
1235    /// # Errors
1236    /// As [`Self::end_offsets`].
1237    pub async fn beginning_offsets(
1238        &mut self,
1239        partitions: &[barnabas_core::group::TopicPartition],
1240    ) -> Result<BTreeMap<barnabas_core::group::TopicPartition, i64>> {
1241        let want: Vec<_> = partitions
1242            .iter()
1243            .cloned()
1244            .map(|tp| (tp, EARLIEST))
1245            .collect();
1246        Ok(self
1247            .list_offsets_many(&want)
1248            .await?
1249            .into_iter()
1250            .map(|(tp, (offset, _))| (tp, offset))
1251            .collect())
1252    }
1253
1254    /// The first offset at or after each timestamp, with the timestamp of the
1255    /// record found.
1256    ///
1257    /// A partition with **no** record at or after its timestamp is absent from
1258    /// the result rather than present with a sentinel, the same distinction
1259    /// [`Self::committed`] draws. Timestamps are milliseconds since the epoch.
1260    ///
1261    /// # Errors
1262    /// As [`Self::end_offsets`].
1263    pub async fn offsets_for_times(
1264        &mut self,
1265        want: &[(barnabas_core::group::TopicPartition, i64)],
1266    ) -> Result<BTreeMap<barnabas_core::group::TopicPartition, (i64, i64)>> {
1267        self.list_offsets_many(want).await
1268    }
1269
1270    /// How far each assigned partition is behind its log end.
1271    ///
1272    /// A partition this consumer has not read from yet has no position, so it
1273    /// is absent — "unknown lag" and "zero lag" are different answers, and an
1274    /// alert built on the second one stays quiet through a consumer that never
1275    /// started.
1276    ///
1277    /// # Errors
1278    /// As [`Self::end_offsets`].
1279    pub async fn lag(&mut self) -> Result<BTreeMap<barnabas_core::group::TopicPartition, i64>> {
1280        let positions = self.positions();
1281        let assigned: Vec<_> = positions.keys().cloned().collect();
1282        let ends = self.end_offsets(&assigned).await?;
1283        Ok(positions
1284            .into_iter()
1285            .filter_map(|(tp, position)| ends.get(&tp).map(|end| (tp, (end - position).max(0))))
1286            .collect())
1287    }
1288
1289    /// Where this consumer's group last committed, for the partitions given.
1290    ///
1291    /// A partition with no committed offset is **absent**, not zero.
1292    ///
1293    /// # Errors
1294    /// If this consumer is not in a group.
1295    pub async fn committed(
1296        &mut self,
1297        partitions: &[barnabas_core::group::TopicPartition],
1298    ) -> Result<BTreeMap<barnabas_core::group::TopicPartition, i64>> {
1299        self.discard_outstanding().await;
1300        let Some(group) = self.group.as_mut() else {
1301            return Err(Error::Missing("a group to read commits from"));
1302        };
1303        crate::group::GroupProtocol::committed(group, &mut self.cluster, partitions).await
1304    }
1305
1306    /// Send one `Fetch` per broker and record what was asked, without waiting.
1307    ///
1308    /// If a send fails partway, whatever was already sent is still recorded —
1309    /// those answers are outstanding whether or not the rest went out, and
1310    /// leaving them unrecorded would strand them on the connection.
1311    async fn issue_fetch(&mut self) -> Result<()> {
1312        let mut by_broker: BTreeMap<String, Vec<(String, i32)>> = BTreeMap::new();
1313        for (topic, partition) in self.fetchable() {
1314            let addr = self.cluster.leader_addr(&topic, partition).await?;
1315            by_broker.entry(addr).or_default().push((topic, partition));
1316        }
1317
1318        // Built before sending: `fetch_request` borrows `self`, and sending
1319        // borrows the cluster mutably.
1320        let planned: Vec<Planned> = by_broker
1321            .into_iter()
1322            .map(|(addr, partitions)| {
1323                let req = self.fetch_request(&addr, &partitions);
1324                (addr, partitions, req)
1325            })
1326            .collect();
1327
1328        let mut sent: Vec<(String, Vec<(String, i32)>)> = Vec::with_capacity(planned.len());
1329        let mut failure = None;
1330        for (addr, partitions, req) in planned {
1331            match self.cluster.send_at(ApiKey::Fetch, 12, &addr, &req).await {
1332                Ok(()) => sent.push((addr, partitions)),
1333                Err(e) => {
1334                    failure = Some(e);
1335                    break;
1336                }
1337            }
1338        }
1339
1340        if sent.is_empty() {
1341            return failure.map_or(Ok(()), Err);
1342        }
1343        self.outstanding = Some(Outstanding {
1344            groups: sent,
1345            generation: self.generation,
1346        });
1347        failure.map_or(Ok(()), Err)
1348    }
1349
1350    /// Put the next round's fetch in flight, if prefetch is on.
1351    ///
1352    /// A failure here is deliberately not surfaced: nothing is outstanding
1353    /// afterwards, so the next [`Self::poll`] issues the request itself and
1354    /// reports whatever goes wrong then. Returning it from *this* call would
1355    /// fail a poll that had already succeeded.
1356    async fn start_prefetch(&mut self) {
1357        if self.prefetch && !self.fetchable().is_empty() {
1358            let _ = self.issue_fetch().await;
1359        }
1360    }
1361
1362    /// Read and throw away an outstanding fetch whose question is stale.
1363    ///
1364    /// The sessions are reset because the broker advanced its own view when it
1365    /// answered; the next request has to be a full fetch for the two to agree.
1366    async fn discard_outstanding(&mut self) {
1367        let Some(outstanding) = self.outstanding.take() else {
1368            return;
1369        };
1370        let addrs: Vec<String> = outstanding
1371            .groups
1372            .iter()
1373            .map(|(addr, _)| addr.clone())
1374            .collect();
1375        self.cluster
1376            .discard_many::<FetchResponse>(ApiKey::Fetch, &addrs)
1377            .await;
1378        for session in self.sessions.values_mut() {
1379            session.reset();
1380        }
1381    }
1382
1383    /// One `Fetch` for this broker.
1384    ///
1385    /// With a session open, only the partitions whose position moved since the
1386    /// last accepted response are named — the broker remembers the rest. That
1387    /// is the whole of KIP-227's benefit: a poll over thirty-two partitions
1388    /// where two are busy sends two.
1389    fn fetch_request(&self, addr: &str, partitions: &[(String, i32)]) -> FetchRequest {
1390        let session = self.sessions.get(addr);
1391        let incremental = self.incremental && session.is_some_and(|s| s.id != 0);
1392
1393        let mut by_topic: BTreeMap<&str, Vec<i32>> = BTreeMap::new();
1394        for (topic, partition) in partitions {
1395            if incremental {
1396                let known = session
1397                    .and_then(|s| s.known.get(&(topic.clone(), *partition)))
1398                    .copied();
1399                let current = self.positions.get(&(topic.clone(), *partition)).copied();
1400                if known == current {
1401                    // The broker already knows where we are on this partition.
1402                    continue;
1403                }
1404            }
1405            by_topic.entry(topic.as_str()).or_default().push(*partition);
1406        }
1407
1408        let topics: Vec<FetchTopic> = by_topic
1409            .into_iter()
1410            .map(|(topic, partitions)| {
1411                let mut fetch_topic = FetchTopic::default();
1412                fetch_topic.topic = TopicName(StrBytes::from_string(topic.to_owned()));
1413                fetch_topic.partitions = partitions
1414                    .into_iter()
1415                    .map(|partition| {
1416                        let mut fetch_partition = FetchPartition::default();
1417                        fetch_partition.partition = partition;
1418                        fetch_partition.fetch_offset = self
1419                            .positions
1420                            .get(&(topic.to_owned(), partition))
1421                            .copied()
1422                            .unwrap_or(0);
1423                        fetch_partition.partition_max_bytes = self.max_bytes;
1424                        fetch_partition.current_leader_epoch = -1;
1425                        fetch_partition.log_start_offset = -1;
1426                        fetch_partition
1427                    })
1428                    .collect();
1429                fetch_topic
1430            })
1431            .collect();
1432
1433        let mut req = FetchRequest::default();
1434        req.replica_id = BrokerId(-1);
1435        req.max_wait_ms = i32::try_from(self.max_wait.as_millis()).unwrap_or(i32::MAX);
1436        req.min_bytes = 1;
1437        req.max_bytes = self.max_response_bytes;
1438        req.isolation_level = self.isolation.as_i8();
1439        req.topics = topics;
1440        if self.incremental {
1441            req.session_id = session.map_or(0, |s| s.id);
1442            req.session_epoch = session.map_or(0, |s| s.epoch);
1443        } else {
1444            // -1 is FINAL_EPOCH: "no session, do not make one".
1445            req.session_epoch = -1;
1446        }
1447        req
1448    }
1449}
1450
1451/// Decode the record batches in one partition's fetch data.
1452///
1453/// **The last batch may be truncated, and that is not corruption.** When a
1454/// fetch hits `max_bytes` the broker cuts the response mid-batch rather than
1455/// dropping it, and expects the client to ignore the fragment and ask again
1456/// from where it got to. A decoder that treats the fragment as an error fails
1457/// the whole fetch — which is exactly what happened the moment several
1458/// partitions shared a response and the limit started binding.
1459///
1460/// So the length prefix is checked before decoding: a batch that is not
1461/// entirely present ends the loop, while a batch that *is* present and fails to
1462/// decode is still an error.
1463fn decode_records(mut bytes: Bytes) -> Result<Vec<Record>> {
1464    /// `baseOffset` (8) + `batchLength` (4) precede the rest of a v2 batch.
1465    const HEADER: usize = 12;
1466
1467    let mut all = Vec::new();
1468    while bytes.len() >= HEADER {
1469        let batch_length = i32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
1470        let Ok(batch_length) = usize::try_from(batch_length) else {
1471            return Err(Error::Core(barnabas_core::Error::Codec(format!(
1472                "record batch declares a negative length: {batch_length}"
1473            ))));
1474        };
1475        if bytes.len() < HEADER + batch_length {
1476            // Truncated by the broker's byte limit. Stop here; the next fetch
1477            // starts from the offset this one reached.
1478            break;
1479        }
1480
1481        let set = RecordBatchDecoder::decode(&mut bytes).map_err(|e| {
1482            Error::Core(barnabas_core::Error::Codec(format!(
1483                "decode record batch: {e}"
1484            )))
1485        })?;
1486        all.extend(set.records);
1487    }
1488    Ok(all)
1489}
1490
1491impl<T: Transport> Consumer<T> {
1492    /// Fetch every assigned partition, **one request per broker**.
1493    ///
1494    /// Records come back grouped in the batches the broker sent, because that
1495    /// is how the format stores them and how the filtering works. Use
1496    /// [`ConsumerRecords::iter`] to walk them without caring; a key, value or
1497    /// header list is materialised when asked for rather than at decode time.
1498    ///
1499    /// An empty result is normal: a fetch that waits out `max_wait` with no new
1500    /// data is not an error. Positions advance past filtered records as well as
1501    /// returned ones, so an all-aborted fetch makes progress rather than
1502    /// looping.
1503    ///
1504    /// All four compression codecs and record headers are handled; only a
1505    /// pre-magic-2 batch falls back to the ordinary decoder, and then only that
1506    /// partition pays the old cost.
1507    ///
1508    /// Filtering happens **per batch** here rather than per record, which it
1509    /// can because `transactional`, `control` and `producer_id` are batch-level
1510    /// in the format. That is most of why this path is cheaper.
1511    ///
1512    /// # Errors
1513    /// As [`Self::poll`].
1514    pub async fn poll(&mut self) -> Result<Vec<ConsumerRecords>> {
1515        // A subscribed consumer keeps its place in the group by polling, which
1516        // is why membership is driven here rather than by a background task:
1517        // this client spawns nothing.
1518        // **Before membership is advanced.** An expansion asks for a rejoin,
1519        // and advancing is what performs one — discovering it afterwards would
1520        // wait a whole poll.
1521        self.check_for_expansion().await?;
1522        if self.group.is_some() {
1523            // **Before membership is advanced, not after.** A rebalance is
1524            // discovered by advancing, and by then the generation that would
1525            // authorise a commit is gone — so the last chance to commit is
1526            // now. It is also why auto-commit is at-least-once: anything read
1527            // since this commit will be read again by whoever takes the
1528            // partition.
1529            self.maybe_auto_commit().await?;
1530            let changed = self.advance_group().await?;
1531            if changed {
1532                self.discard_outstanding().await;
1533            }
1534        }
1535        // Not `positions`: a consumer with every partition paused still has an
1536        // assignment, and must still have polled — the heartbeat above is what
1537        // keeps it in the group.
1538        if self.fetchable().is_empty() {
1539            return Ok(Vec::new());
1540        }
1541        if self
1542            .outstanding
1543            .as_ref()
1544            .is_some_and(|o| o.generation != self.generation)
1545        {
1546            self.discard_outstanding().await;
1547        }
1548        if self.outstanding.is_none() {
1549            self.issue_fetch().await?;
1550        }
1551
1552        let groups = self.outstanding.take().expect("just issued").groups;
1553        let addrs: Vec<String> = groups.iter().map(|(addr, _)| addr.clone()).collect();
1554        let responses = self
1555            .cluster
1556            .recv_many::<FetchResponse>(ApiKey::Fetch, &addrs)
1557            .await;
1558
1559        let mut out = Vec::new();
1560        for ((addr, partitions), response) in groups.into_iter().zip(responses) {
1561            let resp = response?;
1562            if matches!(
1563                resp.error_code,
1564                FETCH_SESSION_ID_NOT_FOUND | INVALID_FETCH_SESSION_EPOCH
1565            ) {
1566                self.sessions.entry(addr.clone()).or_default().reset();
1567                continue;
1568            }
1569            check("Fetch", resp.error_code)?;
1570
1571            if self.incremental {
1572                let session = self.sessions.entry(addr.clone()).or_default();
1573                session.id = resp.session_id;
1574                session.epoch = session.epoch.wrapping_add(1).max(1);
1575                for (topic, partition) in &partitions {
1576                    if let Some(offset) = self.positions.get(&(topic.clone(), *partition)) {
1577                        session.known.insert((topic.clone(), *partition), *offset);
1578                    }
1579                }
1580            }
1581
1582            for topic_response in &resp.responses {
1583                let topic = topic_response.topic.0.to_string();
1584                for part in &topic_response.partitions {
1585                    check("Fetch partition", part.error_code)?;
1586                    let key = (topic.clone(), part.partition_index);
1587                    let Some(fetch_offset) = self.positions.get(&key).copied() else {
1588                        continue;
1589                    };
1590                    let Some(bytes) = part.records.clone().filter(|b| !b.is_empty()) else {
1591                        continue;
1592                    };
1593
1594                    let Some(decoded) = barnabas_core::records::decode_lean(&bytes)? else {
1595                        // Only a pre-magic-2 batch reaches this now: compression
1596                        // and headers are both handled. Kept because a broker
1597                        // holding very old data can still serve it, and being
1598                        // wrong here means bad records rather than an error.
1599                        let records = decode_records(bytes)?;
1600                        let aborted = aborted_of(part);
1601                        let Fetched {
1602                            records,
1603                            next_offset,
1604                        } = consumer::filter(
1605                            records,
1606                            &aborted,
1607                            part.last_stable_offset,
1608                            self.isolation,
1609                            fetch_offset,
1610                        );
1611                        self.positions.insert(key, next_offset);
1612                        if !records.is_empty() {
1613                            out.push(ConsumerRecords {
1614                                topic: topic.clone(),
1615                                partition: part.partition_index,
1616                                batches: Vec::new(),
1617                                fallback: records,
1618                            });
1619                        }
1620                        continue;
1621                    };
1622
1623                    let aborted = aborted_of(part);
1624                    let (batches, next_offset) = barnabas_core::records::filter_batches(
1625                        decoded,
1626                        &aborted,
1627                        part.last_stable_offset,
1628                        self.isolation,
1629                        fetch_offset,
1630                    );
1631                    self.positions.insert(key, next_offset);
1632                    if !batches.is_empty() {
1633                        out.push(ConsumerRecords {
1634                            topic: topic.clone(),
1635                            partition: part.partition_index,
1636                            batches,
1637                            fallback: Vec::new(),
1638                        });
1639                    }
1640                }
1641            }
1642        }
1643
1644        self.start_prefetch().await;
1645        Ok(out)
1646    }
1647}
1648
1649/// The aborted-transaction list a fetch response carries for one partition.
1650fn aborted_of(
1651    part: &kafka_protocol::messages::fetch_response::PartitionData,
1652) -> Vec<AbortedTransaction> {
1653    part.aborted_transactions
1654        .as_ref()
1655        .map(|list| {
1656            list.iter()
1657                .map(|a| AbortedTransaction {
1658                    producer_id: a.producer_id.0,
1659                    first_offset: a.first_offset,
1660                })
1661                .collect()
1662        })
1663        .unwrap_or_default()
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::*;
1669    use bytes::BytesMut;
1670    use kafka_protocol::records::{
1671        Compression, Record, RecordBatchEncoder, RecordEncodeOptions, TimestampType,
1672    };
1673
1674    fn batch(base_offset: i64, count: usize) -> Bytes {
1675        let records: Vec<Record> = (0..count)
1676            .map(|i| Record {
1677                transactional: false,
1678                control: false,
1679                partition_leader_epoch: 0,
1680                producer_id: -1,
1681                producer_epoch: -1,
1682                timestamp_type: TimestampType::Creation,
1683                offset: base_offset + i as i64,
1684                sequence: i as i32,
1685                timestamp: 0,
1686                key: None,
1687                value: Some(Bytes::from(format!("v{i}"))),
1688                headers: Default::default(),
1689            })
1690            .collect();
1691        let mut buf = BytesMut::new();
1692        RecordBatchEncoder::encode(
1693            &mut buf,
1694            records.iter(),
1695            &RecordEncodeOptions {
1696                version: 2,
1697                compression: Compression::None,
1698            },
1699        )
1700        .expect("encode");
1701        buf.freeze()
1702    }
1703
1704    #[test]
1705    fn whole_batches_decode() {
1706        let mut wire = BytesMut::new();
1707        wire.extend_from_slice(&batch(0, 3));
1708        wire.extend_from_slice(&batch(3, 2));
1709        let records = decode_records(wire.freeze()).expect("decode");
1710        assert_eq!(records.len(), 5);
1711    }
1712
1713    /// **A fetch that hits `max_bytes` ends mid-batch**, and the broker expects
1714    /// the fragment to be ignored rather than treated as corruption. Failing
1715    /// here fails the whole fetch — which is what happened the moment several
1716    /// partitions shared one response and the limit started binding.
1717    #[test]
1718    fn a_truncated_trailing_batch_is_ignored() {
1719        let complete = batch(0, 3);
1720        let partial = batch(3, 2);
1721
1722        let mut wire = BytesMut::new();
1723        wire.extend_from_slice(&complete);
1724        wire.extend_from_slice(&partial[..partial.len() - 4]);
1725
1726        let records = decode_records(wire.freeze()).expect("a truncated tail is not an error");
1727        assert_eq!(
1728            records.len(),
1729            3,
1730            "the complete batch must survive and the fragment must be dropped"
1731        );
1732    }
1733
1734    /// Even a fragment too short to hold a header is just "nothing more here".
1735    #[test]
1736    fn a_fragment_shorter_than_a_header_is_ignored() {
1737        let mut wire = BytesMut::new();
1738        wire.extend_from_slice(&batch(0, 1));
1739        wire.extend_from_slice(&[0u8; 5]);
1740        assert_eq!(decode_records(wire.freeze()).expect("decode").len(), 1);
1741    }
1742
1743    /// A batch that claims a negative length is corruption, not truncation, and
1744    /// must not be silently skipped.
1745    #[test]
1746    fn a_negative_batch_length_is_an_error() {
1747        let mut wire = BytesMut::new();
1748        wire.extend_from_slice(&0i64.to_be_bytes());
1749        wire.extend_from_slice(&(-1i32).to_be_bytes());
1750        wire.extend_from_slice(&[0u8; 32]);
1751        assert!(decode_records(wire.freeze()).is_err());
1752    }
1753}