1use std::collections::BTreeMap;
4
5use krabka_client_core::ConnectionOptions;
6use krabka_protocol::owned::{
7 list_offsets_request::{self, ListOffsetsPartition, ListOffsetsRequest, ListOffsetsTopic},
8 list_offsets_response::ListOffsetsResponse,
9};
10
11use crate::{
12 AdminClient, AdminError, KafkaError, kafka_error_name,
13 partition_leaders::{PartitionKey, PartitionResults, complete_results},
14};
15
16const UNSUPPORTED_VERSION: i16 = 35;
18const CONSUMER_REPLICA_ID: i32 = -1;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum OffsetSpec {
24 Earliest,
26 Latest,
29 MaxTimestamp,
32 EarliestLocal,
35 LatestTiered,
38 EarliestPendingUpload,
42 ForTimestamp(i64),
45}
46
47impl OffsetSpec {
48 const fn wire_timestamp(self) -> i64 {
51 match self {
52 Self::Earliest => -2,
53 Self::Latest => -1,
54 Self::MaxTimestamp => -3,
55 Self::EarliestLocal => -4,
56 Self::LatestTiered => -5,
57 Self::EarliestPendingUpload => -6,
58 Self::ForTimestamp(timestamp) => timestamp,
59 }
60 }
61
62 const fn min_version(self) -> i16 {
66 match self {
67 Self::Earliest | Self::Latest | Self::ForTimestamp(_) => 1,
68 Self::MaxTimestamp => 7,
69 Self::EarliestLocal => 8,
70 Self::LatestTiered => 9,
71 Self::EarliestPendingUpload => 11,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub enum IsolationLevel {
79 #[default]
81 ReadUncommitted,
82 ReadCommitted,
84}
85
86impl IsolationLevel {
87 const fn wire(self) -> i8 {
88 match self {
89 Self::ReadUncommitted => 0,
90 Self::ReadCommitted => 1,
91 }
92 }
93
94 const fn min_version(self) -> i16 {
95 match self {
96 Self::ReadUncommitted => 1,
97 Self::ReadCommitted => 2,
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct ListedOffset {
106 pub offset: i64,
108 pub timestamp_ms: i64,
111 pub leader_epoch: Option<i32>,
114}
115
116fn list_offsets_request(
119 keys: &[PartitionKey],
120 specs: &BTreeMap<PartitionKey, OffsetSpec>,
121 isolation_level: IsolationLevel,
122 timeout_ms: i32,
123) -> ListOffsetsRequest {
124 let mut by_topic = BTreeMap::<&str, Vec<ListOffsetsPartition>>::new();
125 for key in keys {
126 let spec = specs.get(key).copied().unwrap_or(OffsetSpec::Latest);
127 by_topic
128 .entry(key.0.as_str())
129 .or_default()
130 .push(ListOffsetsPartition {
131 partition_index: key.1,
132 timestamp: spec.wire_timestamp(),
133 ..Default::default()
134 });
135 }
136 ListOffsetsRequest {
137 replica_id: CONSUMER_REPLICA_ID,
138 isolation_level: isolation_level.wire(),
139 topics: by_topic
140 .into_iter()
141 .map(|(name, partitions)| ListOffsetsTopic {
142 name: name.to_owned(),
143 partitions,
144 ..Default::default()
145 })
146 .collect(),
147 timeout_ms,
148 ..Default::default()
149 }
150}
151
152fn list_offsets_results(
154 keys: &[PartitionKey],
155 response: ListOffsetsResponse,
156) -> PartitionResults<ListedOffset> {
157 let mut out = BTreeMap::new();
158 for topic in response.topics {
159 for partition in topic.partitions {
160 let result = if partition.error_code == 0 {
161 Ok(ListedOffset {
162 offset: partition.offset,
163 timestamp_ms: partition.timestamp,
164 leader_epoch: (partition.leader_epoch != -1).then_some(partition.leader_epoch),
165 })
166 } else {
167 Err(KafkaError {
168 code: partition.error_code,
169 name: kafka_error_name(partition.error_code),
170 message: None,
171 })
172 };
173 out.insert((topic.name.clone(), partition.partition_index), result);
174 }
175 }
176 complete_results("ListOffsets", keys, out)
177}
178
179const fn list_offsets_retry_code(code: i16) -> bool {
186 matches!(
187 code,
188 2 | 3 | 5 | 6 | 7
191 | 9 | 13 | 14 | 15 | 16
193 | 19 | 20 | 41
195 | 51 | 56
197 | 70 | 71 | 72 | 74 | 75
200 | 78 | 80 | 83 | 88
203 | 100 | 103
205 )
206}
207
208const fn required_version(spec: OffsetSpec, isolation_level: IsolationLevel) -> i16 {
211 let spec_version = spec.min_version();
212 let isolation_version = isolation_level.min_version();
213 if spec_version > isolation_version {
214 spec_version
215 } else {
216 isolation_version
217 }
218}
219
220fn split_supported(
225 keys: Vec<PartitionKey>,
226 specs: &BTreeMap<PartitionKey, OffsetSpec>,
227 isolation_level: IsolationLevel,
228 max_version: i16,
229) -> (Vec<PartitionKey>, PartitionResults<ListedOffset>) {
230 let mut supported = Vec::new();
231 let mut unsupported = BTreeMap::new();
232 for key in keys {
233 let spec = specs.get(&key).copied().unwrap_or(OffsetSpec::Latest);
234 let needed = required_version(spec, isolation_level);
235 if needed <= max_version {
236 supported.push(key);
237 } else {
238 unsupported.insert(
239 key,
240 Err(KafkaError {
241 code: UNSUPPORTED_VERSION,
242 name: kafka_error_name(UNSUPPORTED_VERSION),
243 message: Some(format!(
244 "the leader supports ListOffsets up to v{max_version}, and {spec:?} with \
245 {isolation_level:?} needs v{needed}"
246 )),
247 }),
248 );
249 }
250 }
251 (supported, unsupported)
252}
253
254async fn list_offsets_on_leader(
256 address: String,
257 options: ConnectionOptions,
258 keys: Vec<PartitionKey>,
259 specs: &BTreeMap<PartitionKey, OffsetSpec>,
260 isolation_level: IsolationLevel,
261 timeout_ms: i32,
262) -> Result<PartitionResults<ListedOffset>, AdminError> {
263 let connection = AdminClient::connect_one(&address, options).await?;
264 let max_version = connection
265 .advertised_api_range(list_offsets_request::API_KEY)
266 .map_or(-1, |(_, max)| {
267 max.min(list_offsets_request::LATEST_STABLE_VERSION)
268 });
269 let (supported, mut out) = split_supported(keys, specs, isolation_level, max_version);
270 if !supported.is_empty() {
271 let request = list_offsets_request(&supported, specs, isolation_level, timeout_ms);
272 let response = connection.send(request).await?;
273 out.extend(list_offsets_results(&supported, response));
274 }
275 Ok(out)
276}
277
278impl AdminClient {
279 pub async fn list_offsets(
308 &self,
309 specs: &BTreeMap<(String, i32), OffsetSpec>,
310 isolation_level: IsolationLevel,
311 ) -> BTreeMap<(String, i32), Result<ListedOffset, KafkaError>> {
312 let keys = specs.keys().cloned().collect::<Vec<_>>();
313 let timeout_ms = i32::try_from(self.retry.timeout.as_millis()).unwrap_or(i32::MAX);
314 self.call_partition_leaders(
315 "ListOffsets",
316 &keys,
317 list_offsets_retry_code,
318 |address, keys| {
319 list_offsets_on_leader(
320 address,
321 self.options.clone(),
322 keys,
323 specs,
324 isolation_level,
325 timeout_ms,
326 )
327 },
328 )
329 .await
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use std::{
336 net::SocketAddr,
337 sync::{Arc, Mutex},
338 };
339
340 use krabka_protocol::owned::list_offsets_response::{
341 ListOffsetsPartitionResponse, ListOffsetsTopicResponse,
342 };
343
344 use super::*;
345 use crate::partition_leaders::test_support::{
346 ApiVersionRange, Seen, decode_request, encode_response, fast_admin, leader_broker,
347 refused_address,
348 };
349
350 fn key(partition: i32) -> PartitionKey {
351 ("orders".to_owned(), partition)
352 }
353
354 #[test]
355 fn request_carries_each_spec_as_kafka_encodes_it() {
356 let specs = maplit::btreemap! {
357 key(0) => OffsetSpec::Earliest,
358 key(1) => OffsetSpec::Latest,
359 key(2) => OffsetSpec::MaxTimestamp,
360 key(3) => OffsetSpec::EarliestLocal,
361 key(4) => OffsetSpec::LatestTiered,
362 key(5) => OffsetSpec::EarliestPendingUpload,
363 ("audit".to_owned(), 0) => OffsetSpec::ForTimestamp(1_700_000_000_000),
364 };
365 let keys = specs.keys().cloned().collect::<Vec<_>>();
366 let partition = |partition_index, timestamp| ListOffsetsPartition {
367 partition_index,
368 timestamp,
369 ..Default::default()
370 };
371
372 assert2::assert!(
373 list_offsets_request(&keys, &specs, IsolationLevel::ReadCommitted, 30_000)
374 == ListOffsetsRequest {
375 replica_id: -1,
376 isolation_level: 1,
377 topics: vec![
378 ListOffsetsTopic {
379 name: "audit".to_owned(),
380 partitions: vec![partition(0, 1_700_000_000_000)],
381 ..Default::default()
382 },
383 ListOffsetsTopic {
384 name: "orders".to_owned(),
385 partitions: vec![
386 partition(0, -2),
387 partition(1, -1),
388 partition(2, -3),
389 partition(3, -4),
390 partition(4, -5),
391 partition(5, -6),
392 ],
393 ..Default::default()
394 },
395 ],
396 timeout_ms: 30_000,
397 ..Default::default()
398 }
399 );
400 }
401
402 #[test]
403 fn required_version_matches_kafka_for_consumer() {
404 for (spec, isolation_level, expected) in [
405 (OffsetSpec::Latest, IsolationLevel::ReadUncommitted, 1),
406 (
407 OffsetSpec::ForTimestamp(5),
408 IsolationLevel::ReadUncommitted,
409 1,
410 ),
411 (OffsetSpec::Earliest, IsolationLevel::ReadCommitted, 2),
412 (OffsetSpec::MaxTimestamp, IsolationLevel::ReadCommitted, 7),
413 (
414 OffsetSpec::EarliestLocal,
415 IsolationLevel::ReadUncommitted,
416 8,
417 ),
418 (OffsetSpec::LatestTiered, IsolationLevel::ReadUncommitted, 9),
419 (
420 OffsetSpec::EarliestPendingUpload,
421 IsolationLevel::ReadUncommitted,
422 11,
423 ),
424 ] {
425 assert2::assert!(
426 required_version(spec, isolation_level) == expected,
427 "{spec:?} {isolation_level:?}"
428 );
429 }
430 }
431
432 #[test]
433 fn results_map_offsets_and_errors_of_each_partition() {
434 let response = ListOffsetsResponse {
435 topics: vec![ListOffsetsTopicResponse {
436 name: "orders".to_owned(),
437 partitions: vec![
438 ListOffsetsPartitionResponse {
439 partition_index: 0,
440 timestamp: 1_700_000_000_000,
441 offset: 42,
442 leader_epoch: 3,
443 ..Default::default()
444 },
445 ListOffsetsPartitionResponse {
446 partition_index: 1,
447 offset: 7,
448 ..Default::default()
449 },
450 ListOffsetsPartitionResponse {
451 partition_index: 2,
452 error_code: 3,
453 ..Default::default()
454 },
455 ],
456 ..Default::default()
457 }],
458 ..Default::default()
459 };
460
461 assert2::assert!(
462 list_offsets_results(&[key(0), key(1), key(2)], response)
463 == maplit::btreemap! {
464 key(0) => Ok(ListedOffset {
465 offset: 42,
466 timestamp_ms: 1_700_000_000_000,
467 leader_epoch: Some(3),
468 }),
469 key(1) => Ok(ListedOffset {
470 offset: 7,
471 timestamp_ms: -1,
472 leader_epoch: None,
473 }),
474 key(2) => Err(KafkaError {
475 code: 3,
476 name: "UNKNOWN_TOPIC_OR_PARTITION",
477 message: None,
478 }),
479 }
480 );
481 }
482
483 fn offsets_answer(request: &ListOffsetsRequest, orders_1_code: i16) -> ListOffsetsResponse {
486 ListOffsetsResponse {
487 topics: request
488 .topics
489 .iter()
490 .map(|topic| ListOffsetsTopicResponse {
491 name: topic.name.clone(),
492 partitions: topic
493 .partitions
494 .iter()
495 .map(|partition| {
496 let (error_code, offset) = if partition.partition_index == 0 {
497 (0, 100)
498 } else {
499 (orders_1_code, 200)
500 };
501 ListOffsetsPartitionResponse {
502 partition_index: partition.partition_index,
503 error_code,
504 offset,
505 leader_epoch: 4,
506 ..Default::default()
507 }
508 })
509 .collect(),
510 ..Default::default()
511 })
512 .collect(),
513 ..Default::default()
514 }
515 }
516
517 struct Case {
519 name: &'static str,
520 max_version: i16,
522 leaders: Vec<Option<SocketAddr>>,
524 orders_1_codes: Vec<i16>,
526 orders_1_spec: OffsetSpec,
528 expected: Result<ListedOffset, KafkaError>,
530 metadata_requests: usize,
532 requests: Vec<Vec<PartitionKey>>,
534 }
535
536 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
542 async fn list_offsets_retries_and_fails_each_partition_as_kafka_does() {
543 let offset_200 = || {
544 Ok(ListedOffset {
545 offset: 200,
546 timestamp_ms: -1,
547 leader_epoch: Some(4),
548 })
549 };
550 let refused = refused_address().await;
551 let cases = [
552 Case {
553 name: "success",
554 max_version: 11,
555 leaders: vec![None],
556 orders_1_codes: vec![0],
557 orders_1_spec: OffsetSpec::MaxTimestamp,
558 expected: offset_200(),
559 metadata_requests: 1,
560 requests: vec![vec![key(0), key(1)]],
561 },
562 Case {
563 name: "not leader or follower finds the leader again",
564 max_version: 11,
565 leaders: vec![None],
566 orders_1_codes: vec![6, 0],
567 orders_1_spec: OffsetSpec::Earliest,
568 expected: offset_200(),
569 metadata_requests: 2,
570 requests: vec![vec![key(0), key(1)], vec![key(1)]],
571 },
572 Case {
573 name: "leader not available finds the leader again",
574 max_version: 11,
575 leaders: vec![None],
576 orders_1_codes: vec![5, 0],
577 orders_1_spec: OffsetSpec::Earliest,
578 expected: offset_200(),
579 metadata_requests: 2,
580 requests: vec![vec![key(0), key(1)], vec![key(1)]],
581 },
582 Case {
583 name: "a refused connection finds the leader again",
584 max_version: 11,
585 leaders: vec![Some(refused), None],
586 orders_1_codes: vec![0],
587 orders_1_spec: OffsetSpec::Latest,
588 expected: offset_200(),
589 metadata_requests: 2,
590 requests: vec![vec![key(0), key(1)]],
591 },
592 Case {
593 name: "topic authorization failed is final",
594 max_version: 11,
595 leaders: vec![None],
596 orders_1_codes: vec![29],
597 orders_1_spec: OffsetSpec::Latest,
598 expected: Err(KafkaError {
599 code: 29,
600 name: "TOPIC_AUTHORIZATION_FAILED",
601 message: None,
602 }),
603 metadata_requests: 1,
604 requests: vec![vec![key(0), key(1)]],
605 },
606 Case {
607 name: "max timestamp below v7 fails only its partition",
608 max_version: 6,
609 leaders: vec![None],
610 orders_1_codes: vec![0],
611 orders_1_spec: OffsetSpec::MaxTimestamp,
612 expected: Err(KafkaError {
613 code: UNSUPPORTED_VERSION,
614 name: "UNSUPPORTED_VERSION",
615 message: Some(
616 "the leader supports ListOffsets up to v6, and MaxTimestamp with \
617 ReadUncommitted needs v7"
618 .to_owned(),
619 ),
620 }),
621 metadata_requests: 1,
622 requests: vec![vec![key(0)]],
623 },
624 ];
625 for case in cases {
626 let seen = Arc::new(Mutex::new(Seen::default()));
627 let codes = case.orders_1_codes.clone();
628 let broker = leader_broker(
629 vec![
630 ApiVersionRange::builder()
631 .api_key(list_offsets_request::API_KEY)
632 .min_version(1)
633 .max_version(case.max_version)
634 .build(),
635 ],
636 list_offsets_request::API_KEY,
637 case.leaders.clone(),
638 Arc::clone(&seen),
639 move |n, version, body| {
640 let flexible = version >= list_offsets_request::FLEXIBLE_MIN;
641 let request: ListOffsetsRequest = decode_request(body, version, flexible);
642 let code = codes[n.min(codes.len() - 1)];
643 encode_response(&offsets_answer(&request, code), version, flexible)
644 },
645 )
646 .await;
647 let admin = fast_admin(broker.addr, krabka_units::secs(5)).await;
648 let specs = maplit::btreemap! {
649 key(0) => OffsetSpec::Latest,
650 key(1) => case.orders_1_spec,
651 };
652
653 let result = admin
654 .list_offsets(&specs, IsolationLevel::ReadUncommitted)
655 .await;
656
657 broker.stop();
658 let seen = seen.lock().expect("seen lock").clone();
659 let requests = seen
660 .requests
661 .iter()
662 .map(|(version, body)| {
663 let flexible = *version >= list_offsets_request::FLEXIBLE_MIN;
664 decode_request::<ListOffsetsRequest>(body, *version, flexible)
665 })
666 .collect::<Vec<_>>();
667 let expected_requests = case
668 .requests
669 .iter()
670 .map(|keys| {
671 let timeout_ms = if case.max_version >= 10 { 5_000 } else { 0 };
673 list_offsets_request(keys, &specs, IsolationLevel::ReadUncommitted, timeout_ms)
674 })
675 .collect::<Vec<_>>();
676 assert2::assert!(
677 (result, seen.metadata_requests, requests)
678 == (
679 maplit::btreemap! {
680 key(0) => Ok(ListedOffset {
681 offset: 100,
682 timestamp_ms: -1,
683 leader_epoch: Some(4),
684 }),
685 key(1) => case.expected,
686 },
687 case.metadata_requests,
688 expected_requests,
689 ),
690 "case {}",
691 case.name
692 );
693 }
694 }
695
696 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
699 async fn list_offsets_times_out_a_partition_that_never_gets_a_leader() {
700 let seen = Arc::new(Mutex::new(Seen::default()));
701 let broker = leader_broker(
702 vec![
703 ApiVersionRange::builder()
704 .api_key(list_offsets_request::API_KEY)
705 .min_version(1)
706 .max_version(11)
707 .build(),
708 ],
709 list_offsets_request::API_KEY,
710 vec![None],
711 Arc::clone(&seen),
712 |_, _, _| Vec::new(),
713 )
714 .await;
715 let admin = fast_admin(broker.addr, krabka_units::millis(300)).await;
716
717 let result = admin
718 .list_offsets(
719 &maplit::btreemap! {
720 ("missing".to_owned(), 0) => OffsetSpec::Latest,
721 },
722 IsolationLevel::ReadUncommitted,
723 )
724 .await;
725
726 broker.stop();
727 let codes = result
728 .into_iter()
729 .map(|(key, result)| (key, result.map_err(|error| error.code)))
730 .collect::<Vec<_>>();
731 let seen = seen.lock().expect("seen lock").clone();
732 assert2::assert!(codes == vec![(("missing".to_owned(), 0), Err(7))]);
733 assert2::assert!(seen.metadata_requests > 1);
734 assert2::assert!(seen.requests.is_empty());
735 }
736}