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}