Skip to main content

krabka_client_admin/
offsets.rs

1//! Partition offset lookup (`ListOffsets`), as Kafka's `Admin.listOffsets`.
2
3use 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
16/// `UNSUPPORTED_VERSION`: the leader cannot answer this offset spec.
17const UNSUPPORTED_VERSION: i16 = 35;
18/// `ListOffsets` `replica_id` of a consumer (`CONSUMER_REPLICA_ID`).
19const CONSUMER_REPLICA_ID: i32 = -1;
20
21/// Which offset of a partition to look up, as Kafka's `OffsetSpec`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum OffsetSpec {
24    /// The first offset of the log (`EARLIEST_TIMESTAMP`, -2).
25    Earliest,
26    /// The next offset to be written, the high watermark or, for
27    /// `READ_COMMITTED`, the last stable offset (`LATEST_TIMESTAMP`, -1).
28    Latest,
29    /// The offset of the record with the largest timestamp
30    /// (`MAX_TIMESTAMP`, -3, KIP-734). Needs `ListOffsets` v7.
31    MaxTimestamp,
32    /// The first offset of the local log of a tiered topic
33    /// (`EARLIEST_LOCAL_TIMESTAMP`, -4, KIP-405). Needs `ListOffsets` v8.
34    EarliestLocal,
35    /// The highest offset copied to tiered storage
36    /// (`LATEST_TIERED_TIMESTAMP`, -5, KIP-1005). Needs `ListOffsets` v9.
37    LatestTiered,
38    /// The first offset not yet copied to tiered storage
39    /// (`EARLIEST_PENDING_UPLOAD_TIMESTAMP`, -6, KIP-1023). Needs
40    /// `ListOffsets` v11.
41    EarliestPendingUpload,
42    /// The first offset whose timestamp is at or after this Kafka epoch
43    /// millisecond.
44    ForTimestamp(i64),
45}
46
47impl OffsetSpec {
48    /// The `timestamp` of the `ListOffsets` partition, as Kafka's
49    /// `ListOffsetsHandler` sets it.
50    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    /// The lowest `ListOffsets` version that can answer this spec, as Kafka's
63    /// `ListOffsetsRequest.Builder.forConsumer` requires it. A timestamp
64    /// lookup needs v1.
65    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/// Which records a `ListOffsets` lookup sees, as Kafka's `IsolationLevel`.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub enum IsolationLevel {
79    /// Every record, including those of open and aborted transactions.
80    #[default]
81    ReadUncommitted,
82    /// Only records below the last stable offset. Needs `ListOffsets` v2.
83    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/// The offset that `list_offsets` found for one partition, as Kafka's
103/// `ListOffsetsResultInfo`.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct ListedOffset {
106    /// The offset, or `-1` when the partition has no such offset.
107    pub offset: i64,
108    /// The timestamp of the record at `offset`, in Kafka epoch milliseconds,
109    /// or `-1`. An instant is a coordinate, so it stays a raw integer.
110    pub timestamp_ms: i64,
111    /// The leader epoch of the record at `offset`, or `None` when the leader
112    /// did not give one.
113    pub leader_epoch: Option<i32>,
114}
115
116/// The `ListOffsets` request for `keys`, grouped by topic, as Kafka's
117/// `ListOffsetsHandler.buildBatchedRequest` builds it for a consumer.
118fn 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
152/// Maps a `ListOffsets` response to a result for each of `keys`.
153fn 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
179/// Whether a `ListOffsets` partition code retries the partition.
180///
181/// Kafka's `ListOffsetsHandler.handlePartitionError` sends
182/// `NOT_LEADER_OR_FOLLOWER` and `LEADER_NOT_AVAILABLE` back to the leader
183/// lookup and retries every other `RetriableException` on the same leader.
184/// These are the codes whose exception is a `RetriableException`.
185const fn list_offsets_retry_code(code: i16) -> bool {
186    matches!(
187        code,
188        // CORRUPT_MESSAGE, UNKNOWN_TOPIC_OR_PARTITION, LEADER_NOT_AVAILABLE,
189        // NOT_LEADER_OR_FOLLOWER, REQUEST_TIMED_OUT
190        2 | 3 | 5 | 6 | 7
191        // REPLICA_NOT_AVAILABLE, NETWORK_EXCEPTION, the coordinator errors
192        | 9 | 13 | 14 | 15 | 16
193        // NOT_ENOUGH_REPLICAS, NOT_ENOUGH_REPLICAS_AFTER_APPEND, NOT_CONTROLLER
194        | 19 | 20 | 41
195        // CONCURRENT_TRANSACTIONS, KAFKA_STORAGE_ERROR
196        | 51 | 56
197        // FETCH_SESSION_ID_NOT_FOUND, INVALID_FETCH_SESSION_EPOCH,
198        // LISTENER_NOT_FOUND, FENCED_LEADER_EPOCH, UNKNOWN_LEADER_EPOCH
199        | 70 | 71 | 72 | 74 | 75
200        // OFFSET_NOT_AVAILABLE, PREFERRED_LEADER_NOT_AVAILABLE,
201        // ELIGIBLE_LEADERS_NOT_AVAILABLE, UNSTABLE_OFFSET_COMMIT
202        | 78 | 80 | 83 | 88
203        // UNKNOWN_TOPIC_ID, INCONSISTENT_TOPIC_ID
204        | 100 | 103
205    )
206}
207
208/// The lowest `ListOffsets` version that the lookup of `spec` with
209/// `isolation_level` needs.
210const 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
220/// Splits `keys` by whether a leader whose highest usable `ListOffsets`
221/// version is `max_version` can answer them. Each key it cannot answer gets
222/// `UNSUPPORTED_VERSION`, as Kafka's `ListOffsetsHandler.handleUnsupportedError`
223/// fails the partitions of an unsupported spec and keeps the others.
224fn 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
254/// Sends one `ListOffsets` request for `keys` to the leader at `address`.
255async 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    /// Looks up the offset that `specs` names for each partition, as Kafka's
280    /// `listOffsets` operation does.
281    ///
282    /// The client finds the leader of each partition with `Metadata` and
283    /// sends one `ListOffsets` request to each leader, all at the same time,
284    /// as Kafka's `ListOffsetsHandler` with its `PartitionLeaderStrategy`
285    /// does. The request asks as a consumer (`replica_id` -1) with
286    /// `isolation_level`, and carries the call timeout as its `timeout_ms`.
287    /// The result has one entry for each partition of `specs`.
288    ///
289    /// A partition goes back to the leader lookup, and the call finds its
290    /// leader again after the backoff, when the lookup names no leader yet,
291    /// when the connection to its leader fails or is lost, and when the
292    /// leader answers a retriable code, such as `NOT_LEADER_OR_FOLLOWER` (6)
293    /// or `LEADER_NOT_AVAILABLE` (5). The call stops at Kafka's default
294    /// `default.api.timeout.ms` (60 s), where each unresolved partition gets
295    /// `REQUEST_TIMED_OUT` (7).
296    ///
297    /// A spec that the leader's `ListOffsets` versions cannot answer, such as
298    /// [`OffsetSpec::MaxTimestamp`] below v7, fails with
299    /// `UNSUPPORTED_VERSION` (35) for its partition only, as Kafka's
300    /// `ListOffsetsHandler.handleUnsupportedError` does.
301    ///
302    /// # Errors
303    ///
304    /// The call itself does not fail. Each partition gets its own
305    /// [`KafkaError`] in the result, such as the final lookup or
306    /// `ListOffsets` error of that partition.
307    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    /// The `ListOffsets` answer to `request`: offset 100 with leader epoch 4
484    /// on `orders`-0, and `orders_1_code` (or offset 200) on `orders`-1.
485    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    /// One mock-broker case of `list_offsets`.
518    struct Case {
519        name: &'static str,
520        /// The highest `ListOffsets` version that the leader advertises.
521        max_version: i16,
522        /// The leader of each `Metadata` answer, `None` for the mock itself.
523        leaders: Vec<Option<SocketAddr>>,
524        /// The code of `orders`-1 in each `ListOffsets` answer.
525        orders_1_codes: Vec<i16>,
526        /// The spec of `orders`-1.
527        orders_1_spec: OffsetSpec,
528        /// The result of `orders`-1.
529        expected: Result<ListedOffset, KafkaError>,
530        /// The `Metadata` requests.
531        metadata_requests: usize,
532        /// The partitions of each `ListOffsets` request.
533        requests: Vec<Vec<PartitionKey>>,
534    }
535
536    /// Apache Kafka's `ListOffsetsHandler` batches the partitions of one
537    /// leader into one request, looks the leader up again after
538    /// `NOT_LEADER_OR_FOLLOWER`, `LEADER_NOT_AVAILABLE` or a lost connection,
539    /// fails a partition on a non-retriable code, and fails only the
540    /// partitions of a spec that the leader's versions cannot answer.
541    #[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                    // `timeout_ms` is on the wire from v10 only.
672                    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    /// Kafka retries a partition whose topic the lookup does not know until
697    /// the call deadline, and then fails it with a `TimeoutException`.
698    #[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}