1use 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 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
159fn 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 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 #[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 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 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 let acks = self.take_piggyback_acks();
255
256 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 self.share_session_epoch = self.share_session_epoch.wrapping_add(1);
285
286 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 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 self.prev_delivered = delivered;
333 tracing::Span::current().record("records", out.len());
334 Ok(out)
335 }
336
337 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 #[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 #[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 pub async fn commit(&mut self) -> Result<(), ConsumerError> {
450 self.flush_pending_acks().await
451 }
452
453 #[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 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 self.prev_delivered.clear();
531 }
532 }
533 out
534 }
535
536 fn topic_id_for(&self, name: &str) -> WireUuid {
542 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
559fn 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); *observed.lock().unwrap() =
701 Some(ShareFetchRequest::decode(&mut body, version).unwrap());
702 encoded.extend_from_slice(&[0]); 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 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 #[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 ×tamp_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}