Skip to main content

krabka_client_consumer/share/
poll.rs

1//! `ShareConsumer::poll` + acknowledgement (`ShareFetch` / `ShareAcknowledge`).
2//!
3//! `poll()` issues one `ShareFetch` over the live assignment. It pairs each
4//! acquired record with the `acquired_records` range that the broker returns,
5//! so each record carries the broker's `delivery_count`. It also keeps the
6//! ranges for the next poll's implicit auto-`Accept`.
7//!
8//! ## Acknowledgement
9//!
10//! - [`Implicit`](super::types::ShareAckMode::Implicit) is the default. The
11//!   *next* `poll()`, and `close()`, implicitly `Accept` every range that the
12//!   previous `poll()` returned. The application does nothing.
13//! - [`Explicit`](super::types::ShareAckMode::Explicit): the application calls
14//!   [`acknowledge`](ShareConsumer::acknowledge) per record. The next `poll()`
15//!   flushes the staged acks as a piggyback, or
16//!   [`commit`](ShareConsumer::commit) flushes them in a standalone
17//!   `ShareAcknowledge`.
18//!
19//! ## Session epoch
20//!
21//! The broker's share-session cache opens at epoch 0 and stores 1. It then
22//! expects every later `ShareFetch` *or* `ShareAcknowledge` to carry the stored
23//! epoch, and it increments the epoch on each accepted request. This module
24//! mirrors that exactly: it sends `self.share_session_epoch` and advances it by
25//! one after every successful `ShareFetch` / `ShareAcknowledge`, the sequence
26//! 0 → 1 → 2 → …. A wrong epoch makes the broker drop the session with
27//! `INVALID_SHARE_SESSION_EPOCH`.
28
29use std::{collections::HashMap, mem, time::Duration};
30
31use krabka_protocol::{
32    owned::{
33        share_acknowledge_request::{
34            AcknowledgePartition, AcknowledgeTopic, AcknowledgementBatch as AckAckBatch,
35            ShareAcknowledgeRequest,
36        },
37        share_acknowledge_response::ShareAcknowledgeResponse,
38        share_fetch_request::{
39            AcknowledgementBatch as FetchAckBatch, FetchPartition, FetchTopic, ShareFetchRequest,
40        },
41        share_fetch_response::PartitionData,
42    },
43    primitives::uuid::Uuid as WireUuid,
44};
45use krabka_units::{
46    ByteSize, Time,
47    convert::{ByteSizeExt as _, TimeExt as _},
48};
49use tokio::time::{Instant, sleep};
50use tracing::field;
51
52use super::{
53    consumer::ShareConsumer,
54    types::{ShareAckMode, ShareAckType, ShareAcquireMode, ShareConsumerRecord},
55};
56use crate::{coordinator, error::ConsumerError, poll::record_timestamp};
57
58fn build_share_fetch_topics(
59    assignment: &[(WireUuid, String, i32)],
60    acks: &HashMap<(WireUuid, i32), Vec<FetchAckBatch>>,
61) -> Vec<FetchTopic> {
62    let mut by_topic: HashMap<WireUuid, Vec<(i32, Vec<FetchAckBatch>)>> = HashMap::new();
63    for (tid, _name, partition) in assignment {
64        let packs = acks.get(&(*tid, *partition)).cloned().unwrap_or_default();
65        by_topic.entry(*tid).or_default().push((*partition, packs));
66    }
67
68    by_topic
69        .into_iter()
70        .map(|(topic_id, parts)| FetchTopic {
71            topic_id,
72            partitions: parts
73                .into_iter()
74                .map(
75                    |(partition_index, acknowledgement_batches)| FetchPartition {
76                        partition_index,
77                        acknowledgement_batches,
78                        ..Default::default()
79                    },
80                )
81                .collect(),
82            ..Default::default()
83        })
84        .collect()
85}
86
87#[bon::builder]
88fn build_share_fetch_request(
89    #[builder(into)] group_id: String,
90    #[builder(into)] member_id: String,
91    share_session_epoch: i32,
92    timeout: Time,
93    min: ByteSize,
94    max: ByteSize,
95    max_records: i32,
96    acquire_mode: ShareAcquireMode,
97    #[builder(default)] topics: Vec<FetchTopic>,
98) -> ShareFetchRequest {
99    ShareFetchRequest {
100        group_id: Some(group_id),
101        member_id: Some(member_id),
102        share_session_epoch,
103        // Truncate rather than round, and floor at zero: `max_wait_ms` is a
104        // wire field, so a fractional millisecond must not round up past the
105        // caller's budget and an elapsed budget must not go negative.
106        max_wait_ms: i32::try_from(timeout.millis_i64_trunc().max(0)).unwrap_or(i32::MAX),
107        min_bytes: min.bytes_i32(),
108        max_bytes: max.bytes_i32(),
109        max_records,
110        batch_size: max_records,
111        share_acquire_mode: acquire_mode.wire(),
112        topics,
113        ..Default::default()
114    }
115}
116
117fn build_share_ack_request(
118    group_id: String,
119    member_id: String,
120    share_session_epoch: i32,
121    is_renew_ack: bool,
122    topics: Vec<AcknowledgeTopic>,
123) -> ShareAcknowledgeRequest {
124    ShareAcknowledgeRequest {
125        group_id: Some(group_id),
126        member_id: Some(member_id),
127        share_session_epoch,
128        is_renew_ack,
129        topics,
130        ..Default::default()
131    }
132}
133
134fn response_has_error(error_code: i16) -> bool {
135    error_code != 0
136}
137
138fn first_share_ack_partition_error(response: &ShareAcknowledgeResponse) -> Option<i16> {
139    response
140        .responses
141        .iter()
142        .flat_map(|topic| &topic.partitions)
143        .map(|partition| partition.error_code)
144        .find(|code| response_has_error(*code))
145}
146
147fn range_len(first: i64, last: i64) -> usize {
148    usize::try_from(last - first + 1).unwrap_or(0)
149}
150
151fn offset_in_range(first: i64, offset: i64, last: i64) -> bool {
152    first <= offset && offset <= last
153}
154
155fn record_offset(base_offset: i64, offset_delta: i32) -> i64 {
156    base_offset + i64::from(offset_delta)
157}
158
159/// The records of one `ShareFetch` partition row.
160///
161/// The function skips control batches. It pairs each record with the
162/// `delivery_count` of the acquired range that holds its offset, or 0 when no
163/// range holds it. Kafka's `ShareCompletedFetch` reads the timestamp and the
164/// timestamp type from the record batch, as the classic consumer does.
165fn share_partition_records(topic_name: &str, part: &PartitionData) -> Vec<ShareConsumerRecord> {
166    let Some(batches) = part.records.as_ref().and_then(|payload| payload.as_v2()) else {
167        return Vec::new();
168    };
169    let mut out = Vec::new();
170    for batch in batches {
171        if batch.attributes.is_control_batch() {
172            continue;
173        }
174        for r in &batch.records {
175            let offset = record_offset(batch.base_offset, r.offset_delta);
176            // Pair the record with the acquired range that contains it to
177            // read the broker's delivery_count for this offset.
178            let delivery_count = part
179                .acquired_records
180                .iter()
181                .find(|ar| offset_in_range(ar.first_offset, offset, ar.last_offset))
182                .map_or(0, |ar| ar.delivery_count);
183            let (timestamp, timestamp_type) = record_timestamp(batch, r);
184            out.push(ShareConsumerRecord {
185                topic: topic_name.to_owned(),
186                partition: part.partition_index,
187                offset,
188                timestamp,
189                timestamp_type,
190                key: r.key.clone(),
191                value: r.value.clone(),
192                headers: r
193                    .headers
194                    .iter()
195                    .map(|header| (header.key.clone(), header.value.clone()))
196                    .collect(),
197                delivery_count,
198            });
199        }
200    }
201    out
202}
203
204impl ShareConsumer {
205    /// Acquire and return the next batch of records.
206    ///
207    /// This method piggybacks the acknowledgements for the previous `poll()` on
208    /// the `ShareFetch`. Those are the implicit auto-`Accept` ranges, or the
209    /// drained explicit `acknowledge()` calls. It then decodes the acquired
210    /// records and pairs each one with the broker-reported `delivery_count`
211    /// from its `acquired_records` range.
212    ///
213    /// With no assignment yet, this method waits for the heartbeat loop to
214    /// assign partitions within `timeout`. The fetch uses the remaining wait
215    /// budget. If the assignment stays empty, it returns empty at the deadline.
216    #[tracing::instrument(
217        name = "share_consumer.poll",
218        level = "debug",
219        skip_all,
220        fields(
221            group_id = %self.group_id,
222            member_id = %self.member_id,
223            session_epoch = self.share_session_epoch,
224            timeout_ms = timeout.millis_i64_trunc(),
225            assigned_partitions = field::Empty,
226            records = field::Empty,
227        ),
228        err(level = "debug")
229    )]
230    /// # Errors
231    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
232    pub async fn poll(&mut self, timeout: Time) -> Result<Vec<ShareConsumerRecord>, ConsumerError> {
233        let deadline = Instant::now() + timeout.to_std();
234        let assignment = loop {
235            // A heartbeat can assign partitions or report a fatal error while
236            // this poll waits for the initial assignment or a rejoin.
237            if let Some(error) = coordinator::take_poll_error(&self.poll_error) {
238                return Err(error);
239            }
240            let assignment = self.assignment.lock().await.clone();
241            if !assignment.is_empty() {
242                break assignment;
243            }
244            let remaining = deadline.saturating_duration_since(Instant::now());
245            if remaining.is_zero() {
246                return Ok(Vec::new());
247            }
248            sleep(remaining.min(Duration::from_millis(20))).await;
249        };
250        tracing::Span::current().record("assigned_partitions", assignment.len());
251
252        // Build the piggyback acknowledgement batches per (topic_id, partition)
253        // from the previous poll, draining the source so each ack is sent once.
254        let acks = self.take_piggyback_acks();
255
256        // Group assigned partitions by topic id, attaching the (topic_id,
257        // partition) acks to the matching partition entry.
258        let topics = build_share_fetch_topics(&assignment, &acks);
259
260        let resp = self
261            .client
262            .send(
263                build_share_fetch_request()
264                    .group_id(self.group_id.clone())
265                    .member_id(self.member_id.clone())
266                    .share_session_epoch(self.share_session_epoch)
267                    .timeout(Time::from_std(
268                        deadline.saturating_duration_since(Instant::now()),
269                    ))
270                    .min(self.fetch_min)
271                    .max(self.fetch_max)
272                    .max_records(self.fetch_max_records)
273                    .acquire_mode(self.acquire_mode)
274                    .topics(topics)
275                    .call(),
276            )
277            .await?;
278
279        if response_has_error(resp.error_code) {
280            return Err(ConsumerError::Server(resp.error_code));
281        }
282        // A successful ShareFetch consumes one session epoch; advance to the
283        // value the broker now expects (it stored ours + 1).
284        self.share_session_epoch = self.share_session_epoch.wrapping_add(1);
285
286        // Reverse-map topic id → name for the returned rows (the fetch response
287        // carries only topic_id). Fall back to the cached topic_names.
288        let name_for: HashMap<WireUuid, String> = assignment
289            .iter()
290            .map(|(tid, name, _)| (*tid, name.clone()))
291            .collect();
292
293        let mut out: Vec<ShareConsumerRecord> = Vec::new();
294        let mut delivered: Vec<(WireUuid, i32, i64, i64)> = Vec::new();
295        for topic in &resp.responses {
296            let topic_name = name_for.get(&topic.topic_id).cloned().unwrap_or_default();
297            for part in &topic.partitions {
298                if response_has_error(part.acknowledge_error_code) {
299                    tracing::warn!(
300                        topic = %topic_name,
301                        partition = part.partition_index,
302                        acknowledge_error_code = part.acknowledge_error_code,
303                        "share fetch piggyback acknowledge error"
304                    );
305                }
306                if response_has_error(part.error_code) {
307                    tracing::warn!(
308                        topic = %topic_name,
309                        partition = part.partition_index,
310                        error_code = part.error_code,
311                        "share fetch partition error"
312                    );
313                    continue;
314                }
315
316                // Remember the acquired ranges for the next implicit auto-Accept.
317                for ar in &part.acquired_records {
318                    delivered.push((
319                        topic.topic_id,
320                        part.partition_index,
321                        ar.first_offset,
322                        ar.last_offset,
323                    ));
324                }
325
326                out.extend(share_partition_records(&topic_name, part));
327            }
328        }
329
330        // Implicit mode auto-Accepts these ranges on the next poll/close; explicit
331        // mode acknowledges per record, so the ranges are not auto-accepted.
332        self.prev_delivered = delivered;
333        tracing::Span::current().record("records", out.len());
334        Ok(out)
335    }
336
337    /// Stage an explicit acknowledgement for `record`, in Explicit mode.
338    ///
339    /// The next [`poll`](ShareConsumer::poll) flushes the ack as a piggyback,
340    /// or [`commit`](ShareConsumer::commit) flushes it in a standalone
341    /// `ShareAcknowledge`.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`ConsumerError::IllegalState`] in
346    /// [`Implicit`](super::types::ShareAckMode::Implicit) mode. In that mode the
347    /// next poll or close auto-`Accept`s every delivered record, so this method
348    /// cannot honor an explicit `acknowledge()`. Staging it would silently leak
349    /// into `pending_acks`, which the implicit path never flushes. This mirrors
350    /// the JVM `KafkaShareConsumer`, which raises `IllegalStateException` if you
351    /// explicitly acknowledge while in implicit acknowledgement mode.
352    pub fn acknowledge(
353        &mut self,
354        record: &ShareConsumerRecord,
355        ack: ShareAckType,
356    ) -> Result<(), ConsumerError> {
357        if self.ack_mode == ShareAckMode::Implicit {
358            return Err(ConsumerError::IllegalState(
359                "acknowledge() is not allowed in implicit ack mode; \
360                 records are auto-accepted on the next poll/close"
361                    .into(),
362            ));
363        }
364        let topic_id = self.topic_id_for(&record.topic);
365        self.pending_acks.push((
366            topic_id,
367            record.partition,
368            record.offset,
369            record.offset,
370            ack.wire(),
371        ));
372        Ok(())
373    }
374
375    /// Renew the acquisition lock on a single delivered `record` (KIP-1222).
376    ///
377    /// This method sends a standalone `ShareAcknowledge` with
378    /// `is_renew_ack = true` and `acknowledge_types = [4]` for the record's
379    /// offset. The Renew wire type extends the broker-side lock deadline without
380    /// changing the record's state. Like [`acknowledge`](ShareConsumer::acknowledge),
381    /// this is only valid in explicit ack mode. It advances the session epoch on
382    /// success.
383    ///
384    /// # Errors
385    ///
386    /// Returns [`ConsumerError::IllegalState`] in
387    /// [`Implicit`](super::types::ShareAckMode::Implicit) mode, where the next
388    /// poll or close auto-accepts the records, so a lock renewal is meaningless.
389    /// Returns [`ConsumerError::Server`] if the broker rejects the renew.
390    #[tracing::instrument(
391        name = "share_consumer.renew",
392        level = "debug",
393        skip_all,
394        fields(
395            group_id = %self.group_id,
396            partition = record.partition,
397            offset = record.offset,
398        ),
399        err(level = "debug")
400    )]
401    pub async fn renew(&mut self, record: &ShareConsumerRecord) -> Result<(), ConsumerError> {
402        if self.ack_mode == ShareAckMode::Implicit {
403            return Err(ConsumerError::IllegalState(
404                "renew() is not allowed in implicit ack mode; \
405                 records are auto-accepted on the next poll/close"
406                    .into(),
407            ));
408        }
409        let topic_id = self.topic_id_for(&record.topic);
410        let topics = build_ack_topics(vec![(
411            topic_id,
412            record.partition,
413            record.offset,
414            record.offset,
415            4,
416        )]);
417
418        let resp = self
419            .client
420            .send(build_share_ack_request(
421                self.group_id.clone(),
422                self.member_id.clone(),
423                self.share_session_epoch,
424                true,
425                topics,
426            ))
427            .await?;
428        if response_has_error(resp.error_code) {
429            return Err(ConsumerError::Server(resp.error_code));
430        }
431        self.share_session_epoch = self.share_session_epoch.wrapping_add(1);
432        if let Some(error_code) = first_share_ack_partition_error(&resp) {
433            return Err(ConsumerError::Server(error_code));
434        }
435        Ok(())
436    }
437
438    /// Flush the staged explicit acknowledgements in a standalone
439    /// `ShareAcknowledge`. This is a no-op when nothing is staged.
440    #[tracing::instrument(
441        name = "share_consumer.commit",
442        level = "debug",
443        skip_all,
444        fields(group_id = %self.group_id, pending_acks = self.pending_acks.len()),
445        err(level = "debug")
446    )]
447    /// # Errors
448    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
449    pub async fn commit(&mut self) -> Result<(), ConsumerError> {
450        self.flush_pending_acks().await
451    }
452
453    /// Drain `pending_acks` into a `ShareAcknowledge`.
454    ///
455    /// This method advances the session epoch on success, because an accepted
456    /// `ShareAcknowledge` consumes one epoch exactly like a `ShareFetch`. It is
457    /// a no-op, with no epoch advance, when nothing is staged.
458    #[tracing::instrument(
459        name = "share_consumer.flush_pending_acks",
460        level = "debug",
461        skip_all,
462        fields(
463            group_id = %self.group_id,
464            session_epoch = self.share_session_epoch,
465            pending_acks = self.pending_acks.len(),
466        ),
467        err(level = "debug")
468    )]
469    pub(crate) async fn flush_pending_acks(&mut self) -> Result<(), ConsumerError> {
470        if self.pending_acks.is_empty() {
471            return Ok(());
472        }
473        let drained = mem::take(&mut self.pending_acks);
474        let topics = build_ack_topics(drained);
475
476        let resp = self
477            .client
478            .send(build_share_ack_request(
479                self.group_id.clone(),
480                self.member_id.clone(),
481                self.share_session_epoch,
482                false,
483                topics,
484            ))
485            .await?;
486        if response_has_error(resp.error_code) {
487            return Err(ConsumerError::Server(resp.error_code));
488        }
489        self.share_session_epoch = self.share_session_epoch.wrapping_add(1);
490        if let Some(error_code) = first_share_ack_partition_error(&resp) {
491            return Err(ConsumerError::Server(error_code));
492        }
493        Ok(())
494    }
495
496    /// Build the piggyback acknowledgement batches for the next `ShareFetch`,
497    /// keyed by `(topic_id, partition)`. This method consumes the source state.
498    ///
499    /// - Implicit: one `Accept` batch per previously-delivered range.
500    /// - Explicit: the drained `pending_acks`, grouped into per-offset batches.
501    fn take_piggyback_acks(&mut self) -> HashMap<(WireUuid, i32), Vec<FetchAckBatch>> {
502        let mut out: HashMap<(WireUuid, i32), Vec<FetchAckBatch>> = HashMap::new();
503        match self.ack_mode {
504            ShareAckMode::Implicit => {
505                for (tid, partition, first, last) in mem::take(&mut self.prev_delivered) {
506                    let count = range_len(first, last);
507                    out.entry((tid, partition))
508                        .or_default()
509                        .push(FetchAckBatch {
510                            first_offset: first,
511                            last_offset: last,
512                            acknowledge_types: vec![ShareAckType::Accept.wire(); count],
513                            ..Default::default()
514                        });
515                }
516            }
517            ShareAckMode::Explicit => {
518                for (tid, partition, first, last, ack) in mem::take(&mut self.pending_acks) {
519                    let count = range_len(first, last);
520                    out.entry((tid, partition))
521                        .or_default()
522                        .push(FetchAckBatch {
523                            first_offset: first,
524                            last_offset: last,
525                            acknowledge_types: vec![ack; count],
526                            ..Default::default()
527                        });
528                }
529                // Explicit mode never auto-accepts; clear any stale ranges.
530                self.prev_delivered.clear();
531            }
532        }
533        out
534    }
535
536    /// Resolve a topic id from a topic name through the live assignment or the
537    /// cached `topic_names`.
538    ///
539    /// This method returns the zero uuid if the name is unknown. The broker then
540    /// rejects the ack, which surfaces the misuse instead of a silent mis-ack.
541    fn topic_id_for(&self, name: &str) -> WireUuid {
542        // The assignment carries (topic_id, name, partition); use it first since
543        // it is the set the application is acking against. `try_lock` keeps this
544        // sync (acknowledge() takes &mut self, not async).
545        if let Ok(assignment) = self.assignment.try_lock()
546            && let Some((tid, _, _)) = assignment.iter().find(|(_, n, _)| n == name)
547        {
548            return *tid;
549        }
550        if let Ok(names) = self.topic_names.try_lock()
551            && let Some((tid, _)) = names.iter().find(|(_, n)| n.as_str() == name)
552        {
553            return *tid;
554        }
555        WireUuid::default()
556    }
557}
558
559/// Group `(topic_id, partition, first, last, ack_wire)` acks into the
560/// `ShareAcknowledge` topic/partition/batch shape. This function coalesces by
561/// topic and by partition.
562fn build_ack_topics(acks: Vec<(WireUuid, i32, i64, i64, i8)>) -> Vec<AcknowledgeTopic> {
563    let mut by_topic: HashMap<WireUuid, HashMap<i32, Vec<AckAckBatch>>> = HashMap::new();
564    for (tid, partition, first, last, ack) in acks {
565        let count = range_len(first, last);
566        by_topic
567            .entry(tid)
568            .or_default()
569            .entry(partition)
570            .or_default()
571            .push(AckAckBatch {
572                first_offset: first,
573                last_offset: last,
574                acknowledge_types: vec![ack; count],
575                ..Default::default()
576            });
577    }
578    by_topic
579        .into_iter()
580        .map(|(topic_id, parts)| AcknowledgeTopic {
581            topic_id,
582            partitions: parts
583                .into_iter()
584                .map(
585                    |(partition_index, acknowledgement_batches)| AcknowledgePartition {
586                        partition_index,
587                        acknowledgement_batches,
588                        ..Default::default()
589                    },
590                )
591                .collect(),
592            ..Default::default()
593        })
594        .collect()
595}
596
597#[cfg(test)]
598mod tests {
599    use std::sync::{Arc, Mutex as StdMutex};
600
601    use assert2::check;
602    use krabka_client_core::Client;
603    use krabka_protocol::tagged_fields::UnknownTaggedFields;
604    use tokio::sync::Mutex;
605    use tokio_util::sync::CancellationToken;
606
607    use super::*;
608    use crate::{consumer::TimestampType, share};
609
610    fn id(n: u8) -> WireUuid {
611        let mut b = [0u8; 16];
612        b[15] = n;
613        WireUuid(b)
614    }
615
616    #[test]
617    fn standalone_ack_surfaces_partition_error() {
618        use krabka_protocol::owned::share_acknowledge_response::{
619            PartitionData, ShareAcknowledgeTopicResponse,
620        };
621
622        let response = ShareAcknowledgeResponse {
623            responses: vec![ShareAcknowledgeTopicResponse {
624                partitions: vec![PartitionData {
625                    error_code: 121,
626                    ..Default::default()
627                }],
628                ..Default::default()
629            }],
630            ..Default::default()
631        };
632
633        assert2::assert!(first_share_ack_partition_error(&response) == Some(121));
634    }
635
636    async fn test_consumer(ack_mode: ShareAckMode) -> ShareConsumer {
637        ShareConsumer {
638            client: Client::builder()
639                .bootstrap("127.0.0.1:1")
640                .client_id("share-poll-test")
641                .build()
642                .await
643                .unwrap(),
644            group_id: "group-a".into(),
645            member_id: "member-a".into(),
646            member_epoch: Arc::new(Mutex::new(3)),
647            assignment: Arc::new(Mutex::new(vec![(id(7), "topic-a".into(), 2)])),
648            topic_names: Arc::new(Mutex::new(HashMap::new())),
649            share_session_epoch: 4,
650            fetch_min: share::DEFAULT_SHARE_CONSUMER_FETCH_MIN,
651            fetch_max: share::DEFAULT_SHARE_CONSUMER_FETCH_MAX,
652            fetch_max_records: share::DEFAULT_SHARE_CONSUMER_FETCH_MAX_RECORDS,
653            acquire_mode: ShareAcquireMode::BatchOptimized,
654            ack_mode,
655            pending_acks: Vec::new(),
656            prev_delivered: Vec::new(),
657            shutdown: CancellationToken::new(),
658            hb_handle: None,
659            poll_error: coordinator::PollErrorSlot::default(),
660        }
661    }
662
663    #[tokio::test]
664    async fn poll_waits_for_assignment_and_fetches_with_the_remaining_budget() {
665        use bytes::{Buf as _, Bytes, BytesMut};
666        use krabka_client_core::MockBroker;
667        use krabka_protocol::{
668            Decode as _, Encode as _,
669            owned::{
670                api_versions_request,
671                api_versions_response::{ApiVersion, ApiVersionsResponse},
672                share_fetch_request,
673                share_fetch_response::{
674                    AcquiredRecords, ShareFetchResponse, ShareFetchableTopicResponse,
675                },
676            },
677            records::{Record, RecordBatch, RecordsPayload},
678        };
679
680        let fetched = Arc::new(StdMutex::new(None));
681        let observed = Arc::clone(&fetched);
682        let broker = MockBroker::start(move |api_key, version, _corr, mut body| {
683            let mut encoded = BytesMut::new();
684            if api_key == api_versions_request::API_KEY {
685                ApiVersionsResponse {
686                    api_keys: vec![ApiVersion {
687                        api_key: share_fetch_request::API_KEY,
688                        min_version: share_fetch_request::MIN_VERSION,
689                        max_version: share_fetch_request::MAX_VERSION,
690                        ..Default::default()
691                    }],
692                    ..Default::default()
693                }
694                .encode(&mut encoded, 0)
695                .unwrap();
696            } else if api_key == share_fetch_request::API_KEY {
697                let client_id_len = usize::try_from(body.get_i16()).unwrap_or(0);
698                body.advance(client_id_len);
699                assert2::assert!(body.get_u8() == 0); // Empty flexible request-header tags.
700                *observed.lock().unwrap() =
701                    Some(ShareFetchRequest::decode(&mut body, version).unwrap());
702                encoded.extend_from_slice(&[0]); // Empty flexible response-header tags.
703                ShareFetchResponse {
704                    responses: vec![ShareFetchableTopicResponse {
705                        topic_id: id(7),
706                        partitions: vec![PartitionData {
707                            partition_index: 2,
708                            records: Some(RecordsPayload::V2(vec![RecordBatch {
709                                base_offset: 7,
710                                records: vec![Record {
711                                    value: Some(Bytes::from_static(b"work")),
712                                    ..Default::default()
713                                }],
714                                ..Default::default()
715                            }])),
716                            acquired_records: vec![AcquiredRecords {
717                                first_offset: 7,
718                                last_offset: 7,
719                                delivery_count: 1,
720                                ..Default::default()
721                            }],
722                            ..Default::default()
723                        }],
724                        ..Default::default()
725                    }],
726                    ..Default::default()
727                }
728                .encode(&mut encoded, version)
729                .unwrap();
730            } else {
731                panic!("unexpected API key {api_key}");
732            }
733            Some(encoded.to_vec())
734        })
735        .await
736        .unwrap();
737        let mut consumer = test_consumer(ShareAckMode::Explicit).await;
738        consumer.client = Client::builder()
739            .bootstrap(broker.addr.to_string())
740            .build()
741            .await
742            .unwrap();
743        consumer.assignment.lock().await.clear();
744        consumer.share_session_epoch = 0;
745        let assignment = Arc::clone(&consumer.assignment);
746        let assigned = tokio::spawn(async move {
747            sleep(Duration::from_millis(100)).await;
748            assignment.lock().await.push((id(7), "topic-a".into(), 2));
749        });
750        let started = Instant::now();
751        let records = consumer.poll(krabka_units::secs(1)).await.unwrap();
752        let elapsed = started.elapsed();
753        assigned.await.unwrap();
754        broker.stop();
755
756        assert2::assert!(elapsed < Duration::from_secs(1));
757        assert2::assert!(records.len() == 1);
758        assert2::assert!(
759            (records[0].offset, records[0].value.as_deref()) == (7, Some(&b"work"[..]))
760        );
761        assert2::assert!(consumer.share_session_epoch == 1);
762        let request = fetched
763            .lock()
764            .unwrap()
765            .clone()
766            .expect("assignment triggers a ShareFetch in this poll");
767        assert2::assert!((1..=900).contains(&request.max_wait_ms));
768        assert2::assert!(request.topics[0].partitions[0].partition_index == 2);
769    }
770
771    #[tokio::test(start_paused = true)]
772    async fn poll_without_assignment_respects_zero_and_finite_deadlines() {
773        let mut consumer = test_consumer(ShareAckMode::Explicit).await;
774        consumer.assignment.lock().await.clear();
775        for timeout_ms in [0, 37] {
776            let started = Instant::now();
777            let records = consumer
778                .poll(krabka_units::millis(timeout_ms))
779                .await
780                .unwrap();
781            assert2::assert!(records.is_empty());
782            assert2::assert!(started.elapsed() == Duration::from_millis(u64::from(timeout_ms)));
783        }
784        assert2::assert!(consumer.share_session_epoch == 4);
785    }
786
787    #[tokio::test(start_paused = true)]
788    async fn poll_notices_fatal_errors_while_waiting_for_assignment() {
789        let mut consumer = test_consumer(ShareAckMode::Explicit).await;
790        consumer.assignment.lock().await.clear();
791        let poll_error = Arc::clone(&consumer.poll_error);
792        let rejected = tokio::spawn(async move {
793            sleep(Duration::from_millis(5)).await;
794            coordinator::report_rejoin_error(
795                &poll_error,
796                ConsumerError::GroupAuthorizationFailed("group-a".into()),
797            );
798        });
799        let started = Instant::now();
800        let result = consumer.poll(krabka_units::secs(1)).await;
801        rejected.await.unwrap();
802        assert2::assert!(
803            matches!(result, Err(ConsumerError::GroupAuthorizationFailed(group)) if group == "group-a")
804        );
805        assert2::assert!(started.elapsed() <= Duration::from_millis(20));
806    }
807
808    fn only<T>(items: &[T]) -> &T {
809        assert2::assert!(items.len() == 1);
810        &items[0]
811    }
812
813    #[test]
814    fn share_fetch_request_preserves_acquire_mode_limits_and_timeout_bounds() {
815        let topic = FetchTopic {
816            topic_id: id(7),
817            partitions: vec![FetchPartition {
818                partition_index: 2,
819                partition_max_bytes: 123,
820                acknowledgement_batches: Vec::new(),
821                ..Default::default()
822            }],
823            ..Default::default()
824        };
825
826        let req = build_share_fetch_request()
827            .group_id("group-a")
828            .member_id("member-a")
829            .share_session_epoch(4)
830            .timeout(krabka_units::millis(250))
831            .min(krabka_units::bytes(7))
832            .max(krabka_units::bytes(65_536))
833            .max_records(37)
834            .acquire_mode(ShareAcquireMode::RecordLimit)
835            .topics(vec![topic.clone()])
836            .call();
837
838        assert2::assert!(
839            req == ShareFetchRequest {
840                group_id: Some("group-a".into()),
841                member_id: Some("member-a".into()),
842                share_session_epoch: 4,
843                max_wait_ms: 250,
844                min_bytes: 7,
845                max_bytes: 65_536,
846                max_records: 37,
847                batch_size: 37,
848                share_acquire_mode: 1,
849                is_renew_ack: false,
850                topics: vec![topic],
851                forgotten_topics_data: Vec::new(),
852                unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
853            }
854        );
855
856        let saturated = build_share_fetch_request()
857            .group_id("group-a")
858            .member_id("member-a")
859            .share_session_epoch(4)
860            .timeout(Time::from_millis(i64::from(u32::MAX)))
861            .min(krabka_units::bytes(7))
862            .max(krabka_units::bytes(65_536))
863            .max_records(37)
864            .acquire_mode(ShareAcquireMode::BatchOptimized)
865            .call();
866        assert2::assert!((saturated.max_wait_ms, saturated.share_acquire_mode) == (i32::MAX, 0));
867
868        // A sub-millisecond remainder truncates: rounding 250.9 ms up to 251
869        // would hold the broker's Fetch open past the caller's budget.
870        let fractional = build_share_fetch_request()
871            .group_id("group-a")
872            .member_id("member-a")
873            .share_session_epoch(4)
874            .timeout(krabka_units::micros(250_900))
875            .min(krabka_units::bytes(7))
876            .max(krabka_units::bytes(65_536))
877            .max_records(37)
878            .acquire_mode(ShareAcquireMode::BatchOptimized)
879            .call();
880        assert2::assert!(fractional.max_wait_ms == 250);
881    }
882
883    #[test]
884    fn share_fetch_request_encodes_default_sizes_as_their_exact_wire_integers() {
885        let req = build_share_fetch_request()
886            .group_id("group-a")
887            .member_id("member-a")
888            .share_session_epoch(0)
889            .timeout(krabka_units::millis(500))
890            .min(krabka_units::bytes(1))
891            .max(krabka_units::mebibytes(50))
892            .max_records(500)
893            .acquire_mode(ShareAcquireMode::BatchOptimized)
894            .call();
895
896        assert2::assert!(
897            req == ShareFetchRequest {
898                group_id: Some("group-a".into()),
899                member_id: Some("member-a".into()),
900                share_session_epoch: 0,
901                max_wait_ms: 500,
902                min_bytes: 1,
903                max_bytes: 52_428_800,
904                max_records: 500,
905                batch_size: 500,
906                share_acquire_mode: 0,
907                is_renew_ack: false,
908                topics: Vec::new(),
909                forgotten_topics_data: Vec::new(),
910                unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
911            }
912        );
913    }
914
915    #[test]
916    fn share_fetch_topics_group_assignment_and_attach_partition_acks() {
917        let ack = FetchAckBatch {
918            first_offset: 10,
919            last_offset: 11,
920            acknowledge_types: vec![ShareAckType::Accept.wire(); 2],
921            ..Default::default()
922        };
923        let mut acks = HashMap::new();
924        acks.insert((id(7), 2), vec![ack.clone()]);
925
926        let topics = build_share_fetch_topics(
927            &[
928                (id(7), "topic-a".into(), 2),
929                (id(7), "topic-a".into(), 3),
930                (id(8), "topic-b".into(), 1),
931            ],
932            &acks,
933        );
934
935        let topic = topics.iter().find(|topic| topic.topic_id == id(7)).unwrap();
936        assert2::assert!(topic.partitions.len() == 2);
937        let part = topic
938            .partitions
939            .iter()
940            .find(|part| part.partition_index == 2)
941            .unwrap();
942        assert2::assert!(
943            (
944                part.partition_max_bytes,
945                part.acknowledgement_batches.as_slice()
946            ) == (0, &[ack][..])
947        );
948        let empty = topic
949            .partitions
950            .iter()
951            .find(|part| part.partition_index == 3)
952            .unwrap();
953        assert2::assert!(empty.acknowledgement_batches.is_empty());
954    }
955
956    #[test]
957    fn share_ack_request_preserves_identity_epoch_renew_flag_and_topics() {
958        let topics = build_ack_topics(vec![(id(7), 2, 10, 12, ShareAckType::Reject.wire())]);
959
960        let req =
961            build_share_ack_request("group-a".into(), "member-a".into(), 5, true, topics.clone());
962
963        assert2::assert!(
964            req == ShareAcknowledgeRequest {
965                group_id: Some("group-a".into()),
966                member_id: Some("member-a".into()),
967                share_session_epoch: 5,
968                is_renew_ack: true,
969                topics,
970                unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
971            }
972        );
973    }
974
975    #[test]
976    fn response_and_record_helpers_preserve_boundaries() {
977        for (name, code, expected) in [("success", 0, false), ("error", 17, true)] {
978            check!(response_has_error(code) == expected, "case {name}");
979        }
980        for (name, first, last, expected) in [
981            ("inclusive range", 10, 12, 3),
982            ("reversed range", 12, 10, 0),
983        ] {
984            check!(range_len(first, last) == expected, "case {name}");
985        }
986        for (name, first, offset, last, expected) in [
987            ("lower bound", 10, 10, 12, true),
988            ("upper bound", 10, 12, 12, true),
989            ("below range", 10, 9, 12, false),
990            ("above range", 10, 13, 12, false),
991        ] {
992            check!(
993                offset_in_range(first, offset, last) == expected,
994                "case {name}"
995            );
996        }
997        check!(record_offset(100, 7) == 107);
998    }
999
1000    #[tokio::test]
1001    async fn acknowledge_rejects_implicit_mode_and_stages_explicit_record() {
1002        let record = ShareConsumerRecord {
1003            topic: "topic-a".into(),
1004            partition: 2,
1005            offset: 10,
1006            timestamp: 0,
1007            timestamp_type: TimestampType::CreateTime,
1008            key: None,
1009            value: None,
1010            headers: Vec::new(),
1011            delivery_count: 1,
1012        };
1013
1014        let mut implicit = test_consumer(ShareAckMode::Implicit).await;
1015        assert2::assert!(
1016            implicit
1017                .acknowledge(&record, ShareAckType::Accept)
1018                .unwrap_err()
1019                .to_string()
1020                .contains("implicit ack mode")
1021        );
1022
1023        let mut explicit = test_consumer(ShareAckMode::Explicit).await;
1024        explicit
1025            .acknowledge(&record, ShareAckType::Release)
1026            .unwrap();
1027        assert2::assert!(
1028            explicit.pending_acks == vec![(id(7), 2, 10, 10, ShareAckType::Release.wire())]
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn renew_rejects_implicit_mode_before_sending() {
1034        let record = ShareConsumerRecord {
1035            topic: "topic-a".into(),
1036            partition: 2,
1037            offset: 10,
1038            timestamp: 0,
1039            timestamp_type: TimestampType::CreateTime,
1040            key: None,
1041            value: None,
1042            headers: Vec::new(),
1043            delivery_count: 1,
1044        };
1045        let mut consumer = test_consumer(ShareAckMode::Implicit).await;
1046
1047        assert2::assert!(
1048            consumer
1049                .renew(&record)
1050                .await
1051                .unwrap_err()
1052                .to_string()
1053                .contains("implicit ack mode")
1054        );
1055    }
1056
1057    #[tokio::test]
1058    async fn renew_sends_the_wire_type_and_preserves_response_epoch_boundaries() {
1059        use bytes::{Buf as _, BytesMut};
1060        use krabka_client_core::MockBroker;
1061        use krabka_protocol::{
1062            Decode as _, Encode as _,
1063            owned::{
1064                api_versions_request,
1065                api_versions_response::{ApiVersion, ApiVersionsResponse},
1066                share_acknowledge_request,
1067                share_acknowledge_response::{PartitionData, ShareAcknowledgeTopicResponse},
1068            },
1069        };
1070
1071        let requests = Arc::new(StdMutex::new(Vec::new()));
1072        let observed = Arc::clone(&requests);
1073        let mut responses = [(0, 0), (17, 0), (0, 121)].into_iter();
1074        let broker = MockBroker::start(move |api_key, version, _corr, mut body| {
1075            let mut encoded = BytesMut::new();
1076            if api_key == api_versions_request::API_KEY {
1077                ApiVersionsResponse {
1078                    api_keys: vec![ApiVersion {
1079                        api_key: share_acknowledge_request::API_KEY,
1080                        min_version: 2,
1081                        max_version: 2,
1082                        ..Default::default()
1083                    }],
1084                    ..Default::default()
1085                }
1086                .encode(&mut encoded, 0)
1087                .unwrap();
1088            } else if api_key == share_acknowledge_request::API_KEY {
1089                let client_id_len = usize::try_from(body.get_i16()).unwrap_or(0);
1090                body.advance(client_id_len);
1091                assert2::assert!(body.get_u8() == 0);
1092                observed
1093                    .lock()
1094                    .unwrap()
1095                    .push(ShareAcknowledgeRequest::decode(&mut body, version).unwrap());
1096                let (error_code, partition_error) =
1097                    responses.next().expect("one response per renew");
1098                encoded.extend_from_slice(&[0]);
1099                ShareAcknowledgeResponse {
1100                    error_code,
1101                    responses: vec![ShareAcknowledgeTopicResponse {
1102                        topic_id: id(7),
1103                        partitions: vec![PartitionData {
1104                            partition_index: 2,
1105                            error_code: partition_error,
1106                            ..Default::default()
1107                        }],
1108                        ..Default::default()
1109                    }],
1110                    ..Default::default()
1111                }
1112                .encode(&mut encoded, version)
1113                .unwrap();
1114            } else {
1115                panic!("unexpected API key {api_key}");
1116            }
1117            Some(encoded.to_vec())
1118        })
1119        .await
1120        .unwrap();
1121        let mut consumer = test_consumer(ShareAckMode::Explicit).await;
1122        consumer.client = Client::builder()
1123            .bootstrap(broker.addr.to_string())
1124            .build()
1125            .await
1126            .unwrap();
1127        let record = ShareConsumerRecord {
1128            topic: "topic-a".into(),
1129            partition: 2,
1130            offset: 10,
1131            timestamp: 0,
1132            timestamp_type: TimestampType::CreateTime,
1133            key: None,
1134            value: None,
1135            headers: Vec::new(),
1136            delivery_count: 1,
1137        };
1138        let mut outcomes = Vec::new();
1139        for _ in 0..3 {
1140            let error_code = match consumer.renew(&record).await {
1141                Ok(()) => 0,
1142                Err(ConsumerError::Server(code)) => code,
1143                Err(error) => panic!("unexpected renew error: {error}"),
1144            };
1145            outcomes.push((error_code, consumer.share_session_epoch));
1146        }
1147        broker.stop();
1148        assert2::assert!(outcomes == vec![(0, 5), (17, 5), (121, 6)]);
1149        let requests = requests.lock().unwrap();
1150        assert2::assert!(requests.len() == 3);
1151        for (request, epoch) in requests.iter().zip([4, 5, 5]) {
1152            assert2::assert!(request.group_id.as_deref() == Some("group-a"));
1153            assert2::assert!(request.member_id.as_deref() == Some("member-a"));
1154            assert2::assert!((request.share_session_epoch, request.is_renew_ack) == (epoch, true));
1155            let topic = only(&request.topics);
1156            assert2::assert!(topic.topic_id == id(7));
1157            let partition = only(&topic.partitions);
1158            assert2::assert!(partition.partition_index == 2);
1159            assert2::assert!(
1160                *only(&partition.acknowledgement_batches)
1161                    == AckAckBatch {
1162                        first_offset: 10,
1163                        last_offset: 10,
1164                        acknowledge_types: vec![4],
1165                        ..Default::default()
1166                    }
1167            );
1168        }
1169    }
1170
1171    #[tokio::test]
1172    async fn take_piggyback_acks_drains_implicit_deliveries_as_accept_ranges() {
1173        let mut consumer = test_consumer(ShareAckMode::Implicit).await;
1174        consumer.prev_delivered = vec![(id(7), 2, 10, 12)];
1175
1176        let acks = consumer.take_piggyback_acks();
1177
1178        assert2::assert!(consumer.prev_delivered.is_empty());
1179        let batch = only(&acks[&(id(7), 2)]);
1180        assert2::assert!(
1181            *batch
1182                == FetchAckBatch {
1183                    first_offset: 10,
1184                    last_offset: 12,
1185                    acknowledge_types: vec![1, 1, 1],
1186                    unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
1187                }
1188        );
1189    }
1190
1191    #[tokio::test]
1192    async fn take_piggyback_acks_drains_explicit_pending_and_clears_stale_deliveries() {
1193        let mut consumer = test_consumer(ShareAckMode::Explicit).await;
1194        consumer.prev_delivered = vec![(id(7), 2, 1, 1)];
1195        consumer.pending_acks = vec![(id(7), 2, 10, 11, ShareAckType::Reject.wire())];
1196
1197        let acks = consumer.take_piggyback_acks();
1198
1199        assert2::assert!(
1200            (
1201                consumer.prev_delivered.is_empty(),
1202                consumer.pending_acks.is_empty()
1203            ) == (true, true)
1204        );
1205        let batch = only(&acks[&(id(7), 2)]);
1206        assert2::assert!(
1207            *batch
1208                == FetchAckBatch {
1209                    first_offset: 10,
1210                    last_offset: 11,
1211                    acknowledge_types: vec![3, 3],
1212                    unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
1213                }
1214        );
1215    }
1216
1217    #[tokio::test]
1218    async fn topic_id_for_prefers_assignment_then_names_and_defaults_unknown() {
1219        let consumer = test_consumer(ShareAckMode::Explicit).await;
1220        consumer
1221            .topic_names
1222            .lock()
1223            .await
1224            .insert(id(8), "topic-b".into());
1225
1226        for (name, expected) in [
1227            ("topic-a", id(7)),
1228            ("topic-b", id(8)),
1229            ("missing", WireUuid::default()),
1230        ] {
1231            assert2::assert!(consumer.topic_id_for(name) == expected);
1232        }
1233    }
1234
1235    #[test]
1236    fn build_ack_topics_groups_offsets_by_topic_and_partition() {
1237        let topics = build_ack_topics(vec![
1238            (id(7), 2, 10, 12, ShareAckType::Accept.wire()),
1239            (id(7), 3, 20, 20, ShareAckType::Release.wire()),
1240            (id(8), 1, 30, 31, ShareAckType::Reject.wire()),
1241        ]);
1242
1243        let topic = topics.iter().find(|topic| topic.topic_id == id(7)).unwrap();
1244        assert2::assert!(topic.partitions.len() == 2);
1245        let part = topic
1246            .partitions
1247            .iter()
1248            .find(|part| part.partition_index == 2)
1249            .unwrap();
1250        let batch = only(&part.acknowledgement_batches);
1251        assert2::assert!(
1252            *batch
1253                == AckAckBatch {
1254                    first_offset: 10,
1255                    last_offset: 12,
1256                    acknowledge_types: vec![1, 1, 1],
1257                    unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
1258                }
1259        );
1260
1261        let renew = build_ack_topics(vec![(id(7), 2, 10, 10, 4)]);
1262        let renew_batch = only(&only(&only(&renew).partitions).acknowledgement_batches);
1263        assert2::assert!(
1264            *renew_batch
1265                == AckAckBatch {
1266                    first_offset: 10,
1267                    last_offset: 10,
1268                    acknowledge_types: vec![4],
1269                    unknown_tagged_fields: UnknownTaggedFields(Vec::new()),
1270                }
1271        );
1272    }
1273
1274    /// Kafka's `ShareCompletedFetch` gives each record the timestamp and the
1275    /// timestamp type of its record batch, as the classic consumer does.
1276    #[test]
1277    fn share_records_take_the_batch_timestamp_type() {
1278        use krabka_protocol::owned::share_fetch_response::AcquiredRecords;
1279
1280        use crate::poll::timestamp_cases;
1281
1282        for case in &timestamp_cases::CASES {
1283            let part = PartitionData {
1284                partition_index: 2,
1285                records: Some(timestamp_cases::batch(case).into()),
1286                acquired_records: vec![AcquiredRecords {
1287                    first_offset: timestamp_cases::BASE_OFFSET,
1288                    last_offset: timestamp_cases::BASE_OFFSET + 1,
1289                    delivery_count: 1,
1290                    ..Default::default()
1291                }],
1292                ..Default::default()
1293            };
1294
1295            let expected: Vec<ShareConsumerRecord> = case
1296                .expected
1297                .iter()
1298                .zip(timestamp_cases::BASE_OFFSET..)
1299                .zip(timestamp_cases::VALUES)
1300                .map(
1301                    |(((timestamp, timestamp_type), offset), value)| ShareConsumerRecord {
1302                        topic: "topic-a".into(),
1303                        partition: 2,
1304                        offset,
1305                        timestamp: *timestamp,
1306                        timestamp_type: *timestamp_type,
1307                        key: None,
1308                        value: Some(bytes::Bytes::from_static(value)),
1309                        headers: Vec::new(),
1310                        delivery_count: 1,
1311                    },
1312                )
1313                .collect();
1314            check!(
1315                share_partition_records("topic-a", &part) == expected,
1316                "case {}",
1317                case.name
1318            );
1319        }
1320    }
1321}