Skip to main content

krabka_client_consumer/
queries.rs

1//! Offset and topic metadata queries: Kafka's `KafkaConsumer.beginningOffsets`,
2//! `endOffsets`, `offsetsForTimes`, `currentLag`, `partitionsFor` and
3//! `listTopics`.
4//!
5//! The queries do not need the partitions to be assigned. They send their own
6//! `Metadata` request for the topics, as Kafka's `OffsetFetcher` adds the
7//! topics as transient topics, and then one `ListOffsets` request per leader.
8
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10
11use krabka_client_core::MetadataScope;
12use krabka_protocol::owned::{
13    list_offsets_request,
14    list_offsets_request::{ListOffsetsPartition, ListOffsetsRequest, ListOffsetsTopic},
15    list_offsets_response::ListOffsetsResponse,
16    metadata_request::MetadataRequest,
17    metadata_response::MetadataResponse,
18};
19use krabka_units::convert::TimeExt as _;
20
21use crate::{
22    IsolationLevel, consumer::Consumer, coordinator::next_backoff, error::ConsumerError, poll,
23    poll::is_reset_sentinel,
24};
25
26/// `TOPIC_AUTHORIZATION_FAILED`.
27const TOPIC_AUTHORIZATION_FAILED: i16 = 29;
28/// `UNKNOWN_TOPIC_OR_PARTITION`.
29const UNKNOWN_TOPIC_OR_PARTITION: i16 = 3;
30/// `INVALID_TOPIC_EXCEPTION`.
31const INVALID_TOPIC_EXCEPTION: i16 = 17;
32/// `UNSUPPORTED_FOR_MESSAGE_FORMAT`.
33const UNSUPPORTED_FOR_MESSAGE_FORMAT: i16 = 43;
34
35/// The offset of a timestamp search. Kafka's `OffsetAndTimestamp`.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct OffsetAndTimestamp {
38    /// The first offset whose timestamp is at or after the searched timestamp.
39    pub offset: i64,
40    /// The timestamp of the record at `offset`.
41    pub timestamp: i64,
42    /// The leader epoch of the record at `offset`, if the broker knows it.
43    pub leader_epoch: Option<i32>,
44}
45
46/// A broker of the cluster. Kafka's `Node`.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Node {
49    pub id: i32,
50    /// The host, or empty when the metadata names no broker with this id.
51    pub host: String,
52    /// The port, or `-1` when the metadata names no broker with this id.
53    pub port: i32,
54    pub rack: Option<String>,
55}
56
57/// The metadata of one partition. Kafka's `PartitionInfo`.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct PartitionInfo {
60    pub topic: String,
61    pub partition: i32,
62    /// The leader, or `None` when the partition has no leader.
63    pub leader: Option<Node>,
64    pub replicas: Vec<Node>,
65    pub in_sync_replicas: Vec<Node>,
66    pub offline_replicas: Vec<Node>,
67}
68
69/// A `ListOffsets` request that searches by timestamp, from version 1. Kafka's
70/// `ListOffsetsRequest.Builder.forConsumer(requireTimestamp = true, ..)` sets
71/// the oldest allowed version to 1, because version 0 has no timestamp.
72#[derive(Clone, Debug, PartialEq, Eq)]
73struct TimestampListOffsets(ListOffsetsRequest);
74
75impl krabka_protocol::Encode for TimestampListOffsets {
76    fn encode<B: bytes::BufMut>(
77        &self,
78        buf: &mut B,
79        version: i16,
80    ) -> Result<(), krabka_protocol::ProtocolError> {
81        self.0.encode(buf, version)
82    }
83
84    fn encoded_len(&self, version: i16) -> usize {
85        self.0.encoded_len(version)
86    }
87}
88
89impl krabka_protocol::ProtocolRequest for TimestampListOffsets {
90    const API_KEY: i16 = list_offsets_request::API_KEY;
91    const MIN_VERSION: i16 = 1;
92    const MAX_VERSION: i16 = list_offsets_request::MAX_VERSION;
93    const LATEST_STABLE_VERSION: i16 = list_offsets_request::LATEST_STABLE_VERSION;
94    const FLEXIBLE_MIN: i16 = list_offsets_request::FLEXIBLE_MIN;
95    type Response = ListOffsetsResponse;
96}
97
98/// What a query takes from one `ListOffsets` partition row.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100enum QueryRow {
101    Found(OffsetAndTimestamp),
102    /// No offset, and no retry: `NONE` with offset `-1`, or
103    /// `UNSUPPORTED_FOR_MESSAGE_FORMAT`.
104    NotFound,
105    Unauthorized,
106    Retry,
107}
108
109/// Kafka's `OffsetFetcherUtils.handleListOffsetResponse` for one row.
110fn classify_query_row(error_code: i16, offset: i64, timestamp: i64, leader_epoch: i32) -> QueryRow {
111    match error_code {
112        0 if offset != -1 => QueryRow::Found(OffsetAndTimestamp {
113            offset,
114            timestamp,
115            leader_epoch: (leader_epoch >= 0).then_some(leader_epoch),
116        }),
117        0 | UNSUPPORTED_FOR_MESSAGE_FORMAT => QueryRow::NotFound,
118        TOPIC_AUTHORIZATION_FAILED => QueryRow::Unauthorized,
119        _ => QueryRow::Retry,
120    }
121}
122
123/// Fail for a requested topic that `metadata` does not authorize or names as
124/// invalid. Kafka's `Metadata.update` records these errors, and the next
125/// `client.poll` of `OffsetFetcher.fetchOffsetsByTimes` throws
126/// `TopicAuthorizationException` or `InvalidTopicException`. Other topic
127/// errors wait for the next round.
128fn requested_topic_errors(
129    metadata: &MetadataResponse,
130    topics: &BTreeSet<String>,
131) -> Result<(), ConsumerError> {
132    let requested = |topic: &&krabka_protocol::owned::metadata_response::MetadataResponseTopic| {
133        topic
134            .name
135            .as_ref()
136            .is_some_and(|name| topics.contains(name))
137    };
138    let unauthorized: BTreeSet<String> = metadata
139        .topics
140        .iter()
141        .filter(requested)
142        .filter(|topic| topic.error_code == TOPIC_AUTHORIZATION_FAILED)
143        .filter_map(|topic| topic.name.clone())
144        .collect();
145    if !unauthorized.is_empty() {
146        return Err(ConsumerError::TopicAuthorizationFailed(unauthorized));
147    }
148    if let Some(name) = metadata
149        .topics
150        .iter()
151        .filter(requested)
152        .find(|topic| topic.error_code == INVALID_TOPIC_EXCEPTION)
153        .and_then(|topic| topic.name.clone())
154    {
155        return Err(ConsumerError::InvalidTopic(name));
156    }
157    Ok(())
158}
159
160/// The leader id and epoch of each partition in `metadata` that has a leader.
161fn partition_leaders(metadata: &MetadataResponse) -> HashMap<(String, i32), (i32, i32)> {
162    metadata
163        .topics
164        .iter()
165        .filter(|topic| topic.error_code == 0)
166        .filter_map(|topic| topic.name.as_ref().map(|name| (name, topic)))
167        .flat_map(|(name, topic)| {
168            topic
169                .partitions
170                .iter()
171                .filter(|partition| partition.leader_id >= 0)
172                .map(move |partition| {
173                    (
174                        (name.clone(), partition.partition_index),
175                        (partition.leader_id, partition.leader_epoch),
176                    )
177                })
178        })
179        .collect()
180}
181
182/// One partition of a `ListOffsets` query: `((topic, partition), current
183/// leader epoch, timestamp)`.
184type QueryPartition = ((String, i32), i32, i64);
185
186/// The `ListOffsets` request for `partitions`.
187fn query_request(
188    partitions: &[QueryPartition],
189    isolation_level: IsolationLevel,
190) -> ListOffsetsRequest {
191    let mut by_topic: BTreeMap<&str, Vec<ListOffsetsPartition>> = BTreeMap::new();
192    for ((topic, partition), leader_epoch, timestamp) in partitions {
193        by_topic
194            .entry(topic)
195            .or_default()
196            .push(ListOffsetsPartition {
197                partition_index: *partition,
198                current_leader_epoch: *leader_epoch,
199                timestamp: *timestamp,
200                ..Default::default()
201            });
202    }
203    ListOffsetsRequest {
204        replica_id: -1,
205        isolation_level: isolation_level.wire(),
206        topics: by_topic
207            .into_iter()
208            .map(|(name, partitions)| ListOffsetsTopic {
209                name: name.to_owned(),
210                partitions,
211                ..Default::default()
212            })
213            .collect(),
214        ..Default::default()
215    }
216}
217
218/// Kafka's `TopicMetadataFetcher.getTopicMetadata` for one response. `None`
219/// means that a topic has a retriable error and the request must be sent
220/// again.
221fn topic_partition_infos(
222    metadata: &MetadataResponse,
223) -> Result<Option<HashMap<String, Vec<PartitionInfo>>>, ConsumerError> {
224    let unauthorized: BTreeSet<String> = metadata
225        .topics
226        .iter()
227        .filter(|topic| topic.error_code == TOPIC_AUTHORIZATION_FAILED)
228        .filter_map(|topic| topic.name.clone())
229        .collect();
230    if !unauthorized.is_empty() {
231        return Err(ConsumerError::TopicAuthorizationFailed(unauthorized));
232    }
233    let mut retry = false;
234    for topic in &metadata.topics {
235        let name = topic.name.clone().unwrap_or_default();
236        match topic.error_code {
237            0 | UNKNOWN_TOPIC_OR_PARTITION => {}
238            INVALID_TOPIC_EXCEPTION => return Err(ConsumerError::InvalidTopic(name)),
239            code if crate::offset_wire::is_retriable_error(code) => retry = true,
240            code => return Err(ConsumerError::Server(code)),
241        }
242    }
243    if retry {
244        return Ok(None);
245    }
246    let node = |id: i32| {
247        metadata
248            .brokers
249            .iter()
250            .find(|broker| broker.node_id == id)
251            .map_or_else(
252                || Node {
253                    id,
254                    host: String::new(),
255                    port: -1,
256                    rack: None,
257                },
258                |broker| Node {
259                    id,
260                    host: broker.host.clone(),
261                    port: broker.port,
262                    rack: broker.rack.clone(),
263                },
264            )
265    };
266    let nodes = |ids: &[i32]| ids.iter().map(|id| node(*id)).collect::<Vec<_>>();
267    Ok(Some(
268        metadata
269            .topics
270            .iter()
271            .filter(|topic| topic.error_code == 0)
272            .filter_map(|topic| topic.name.as_ref().map(|name| (name, topic)))
273            .map(|(name, topic)| {
274                let partitions = topic
275                    .partitions
276                    .iter()
277                    .map(|partition| PartitionInfo {
278                        topic: name.clone(),
279                        partition: partition.partition_index,
280                        leader: metadata
281                            .brokers
282                            .iter()
283                            .any(|broker| broker.node_id == partition.leader_id)
284                            .then(|| node(partition.leader_id)),
285                        replicas: nodes(&partition.replica_nodes),
286                        in_sync_replicas: nodes(&partition.isr_nodes),
287                        offline_replicas: nodes(&partition.offline_replicas),
288                    })
289                    .collect();
290                (name.clone(), partitions)
291            })
292            .collect(),
293    ))
294}
295
296impl Consumer {
297    /// The first offset of each partition. Kafka's
298    /// `KafkaConsumer.beginningOffsets`.
299    ///
300    /// A partition without an offset is not in the result.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`ConsumerError::TopicAuthorizationFailed`],
305    /// [`ConsumerError::Timeout`] when some offsets are not known before
306    /// `default_api_timeout`, or a request error that is not retriable.
307    pub async fn beginning_offsets(
308        &self,
309        partitions: &[(String, i32)],
310    ) -> Result<HashMap<(String, i32), i64>, ConsumerError> {
311        self.boundary_offsets(partitions, poll::EARLIEST_TIMESTAMP)
312            .await
313    }
314
315    /// The end offset of each partition: the high watermark, or the last
316    /// stable offset with `read_committed`. Kafka's `KafkaConsumer.endOffsets`.
317    ///
318    /// A partition without an offset is not in the result.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`ConsumerError::TopicAuthorizationFailed`],
323    /// [`ConsumerError::Timeout`] when some offsets are not known before
324    /// `default_api_timeout`, or a request error that is not retriable.
325    pub async fn end_offsets(
326        &self,
327        partitions: &[(String, i32)],
328    ) -> Result<HashMap<(String, i32), i64>, ConsumerError> {
329        self.boundary_offsets(partitions, poll::LATEST_TIMESTAMP)
330            .await
331    }
332
333    async fn boundary_offsets(
334        &self,
335        partitions: &[(String, i32)],
336        timestamp: i64,
337    ) -> Result<HashMap<(String, i32), i64>, ConsumerError> {
338        let search = partitions
339            .iter()
340            .map(|partition| (partition.clone(), timestamp))
341            .collect();
342        Ok(self
343            .search_offsets(search, false)
344            .await?
345            .into_iter()
346            .map(|(partition, found)| (partition, found.offset))
347            .collect())
348    }
349
350    /// The first offset of each partition whose timestamp is at or after the
351    /// given timestamp. Kafka's `KafkaConsumer.offsetsForTimes`.
352    ///
353    /// A partition without such a record maps to `None`.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`ConsumerError::InvalidArgument`] for a negative timestamp,
358    /// [`ConsumerError::TopicAuthorizationFailed`], [`ConsumerError::Timeout`]
359    /// when some offsets are not known before `default_api_timeout`, or a
360    /// request error that is not retriable.
361    pub async fn offsets_for_times(
362        &self,
363        timestamps: &HashMap<(String, i32), i64>,
364    ) -> Result<HashMap<(String, i32), Option<OffsetAndTimestamp>>, ConsumerError> {
365        if let Some(((topic, partition), timestamp)) =
366            timestamps.iter().find(|(_, timestamp)| **timestamp < 0)
367        {
368            return Err(ConsumerError::InvalidArgument(format!(
369                "The target time for partition {topic}-{partition} is {timestamp}. The target time cannot be negative."
370            )));
371        }
372        let mut found = self.search_offsets(timestamps.clone(), true).await?;
373        Ok(timestamps
374            .keys()
375            .map(|partition| (partition.clone(), found.remove(partition)))
376            .collect())
377    }
378
379    /// The number of records between the position of an assigned partition
380    /// and its end offset from the last fetch. Kafka's
381    /// `KafkaConsumer.currentLag`.
382    ///
383    /// `None` when the position or the end offset is not known.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`ConsumerError::NoCurrentAssignment`] if the consumer does not
388    /// own the partition.
389    pub async fn current_lag(
390        &self,
391        topic: impl Into<String>,
392        partition: i32,
393    ) -> Result<Option<i64>, ConsumerError> {
394        let key = (topic.into(), partition);
395        let assigned = self.assigned.lock().await;
396        if !assigned.contains(&key) {
397            let (topic, partition) = key;
398            return Err(ConsumerError::NoCurrentAssignment { topic, partition });
399        }
400        let offsets = self.next_offsets.lock().await;
401        // Kafka's `partitionLag` needs a valid position: not a pending reset,
402        // and not one that waits for validation.
403        let awaiting_validation = self
404            .positions
405            .lock()
406            .await
407            .get(&key)
408            .is_some_and(|position| position.awaiting_validation);
409        let position = offsets
410            .get(&key)
411            .copied()
412            .filter(|offset| !is_reset_sentinel(*offset) && !awaiting_validation);
413        drop(offsets);
414        drop(assigned);
415        let end = self.end_offsets.lock().await.get(&key).copied();
416        Ok(position.zip(end).map(|(position, end)| end - position))
417    }
418
419    /// Send `ListOffsets` for each partition of `search` with its timestamp,
420    /// until each partition has an answer. Kafka's
421    /// `OffsetFetcher.fetchOffsetsByTimes`.
422    ///
423    /// Each round sends `Metadata` for the topics and one `ListOffsets` to
424    /// each leader. A partition without a leader, a retriable row or a
425    /// transport error waits for the next round. `require_timestamps` (a
426    /// timestamp search) and `read_committed` need `ListOffsets` version 2 or
427    /// higher.
428    async fn search_offsets(
429        &self,
430        search: HashMap<(String, i32), i64>,
431        require_timestamps: bool,
432    ) -> Result<HashMap<(String, i32), OffsetAndTimestamp>, ConsumerError> {
433        let started = tokio::time::Instant::now();
434        let deadline = started + self.default_api_timeout.to_std();
435        // The deadline bounds each request too, as Kafka's timer bounds each
436        // `client.poll`.
437        tokio::time::timeout_at(
438            deadline,
439            self.search_offsets_until(search, require_timestamps, started, deadline),
440        )
441        .await
442        .unwrap_or_else(|_| {
443            Err(ConsumerError::timeout(format!(
444                "Failed to get offsets by times in {}ms",
445                started.elapsed().as_millis()
446            )))
447        })
448    }
449
450    async fn search_offsets_until(
451        &self,
452        mut search: HashMap<(String, i32), i64>,
453        require_timestamps: bool,
454        started: tokio::time::Instant,
455        deadline: tokio::time::Instant,
456    ) -> Result<HashMap<(String, i32), OffsetAndTimestamp>, ConsumerError> {
457        let mut backoff = self.retry_policy.initial_backoff;
458        let mut found = HashMap::new();
459        while !search.is_empty() {
460            let topics: BTreeSet<String> = search.keys().map(|(topic, _)| topic.clone()).collect();
461            let request = krabka_client_core::topics_request(
462                topics.iter().cloned(),
463                self.allows_auto_topic_creation(),
464            );
465            match self.client.refresh_metadata_with(request).await {
466                Ok(metadata) => {
467                    requested_topic_errors(&metadata, &topics)?;
468                    let leaders = partition_leaders(&metadata);
469                    let mut by_leader: BTreeMap<i32, Vec<QueryPartition>> = BTreeMap::new();
470                    for (partition, timestamp) in &search {
471                        if let Some((leader, epoch)) = leaders.get(partition)
472                            && self.client.knows_broker(*leader)
473                        {
474                            by_leader.entry(*leader).or_default().push((
475                                partition.clone(),
476                                *epoch,
477                                *timestamp,
478                            ));
479                        }
480                    }
481                    let mut unauthorized = BTreeSet::new();
482                    for (leader, partitions) in by_leader {
483                        let request = query_request(&partitions, self.isolation_level);
484                        let broker = self.client.broker(leader);
485                        // Kafka's `ListOffsetsRequest.Builder.forConsumer`:
486                        // version 2 for `read_committed`, version 1 for a
487                        // timestamp search.
488                        let answer = if self.isolation_level == IsolationLevel::ReadCommitted {
489                            broker
490                                .send(poll::ReadCommittedListOffsets::new(request))
491                                .await
492                        } else if require_timestamps {
493                            broker.send(TimestampListOffsets(request)).await
494                        } else {
495                            broker.send(request).await
496                        };
497                        match answer {
498                            Ok(answer) => {
499                                apply_query_answer(
500                                    &answer,
501                                    &mut search,
502                                    &mut found,
503                                    &mut unauthorized,
504                                );
505                            }
506                            Err(error) if poll::is_transient_transport_error(&error) => {
507                                self.client.evict_broker(leader);
508                            }
509                            Err(error) => return Err(error.into()),
510                        }
511                    }
512                    if !unauthorized.is_empty() {
513                        return Err(ConsumerError::TopicAuthorizationFailed(unauthorized));
514                    }
515                }
516                Err(error) if poll::is_transient_transport_error(&error) => {}
517                Err(error) => return Err(error.into()),
518            }
519            if search.is_empty() {
520                break;
521            }
522            if tokio::time::Instant::now() >= deadline {
523                return Err(ConsumerError::timeout(format!(
524                    "Failed to get offsets by times in {}ms",
525                    started.elapsed().as_millis()
526                )));
527            }
528            tokio::time::sleep_until((tokio::time::Instant::now() + backoff).min(deadline)).await;
529            backoff = next_backoff(backoff, self.retry_policy.max_backoff);
530        }
531        Ok(found)
532    }
533
534    /// The partitions of `topic`. Kafka's `KafkaConsumer.partitionsFor`.
535    ///
536    /// An unknown topic gives an empty list. With `allow_auto_create_topics`,
537    /// the `Metadata` request lets the broker create the topic.
538    ///
539    /// # Errors
540    ///
541    /// Returns [`ConsumerError::TopicAuthorizationFailed`],
542    /// [`ConsumerError::InvalidTopic`], [`ConsumerError::Timeout`] when a
543    /// retriable error lasts past `default_api_timeout`, or a request error.
544    pub async fn partitions_for(&self, topic: &str) -> Result<Vec<PartitionInfo>, ConsumerError> {
545        let request = krabka_client_core::topics_request(
546            [topic.to_owned()],
547            self.allows_auto_topic_creation(),
548        );
549        Ok(self
550            .topic_metadata(request)
551            .await?
552            .remove(topic)
553            .unwrap_or_default())
554    }
555
556    /// The partitions of every topic of the cluster. Kafka's
557    /// `KafkaConsumer.listTopics`.
558    ///
559    /// # Errors
560    ///
561    /// Returns [`ConsumerError::TopicAuthorizationFailed`],
562    /// [`ConsumerError::Timeout`] when a retriable error lasts past
563    /// `default_api_timeout`, or a request error.
564    pub async fn list_topics(&self) -> Result<HashMap<String, Vec<PartitionInfo>>, ConsumerError> {
565        self.topic_metadata(krabka_client_core::all_topics_request())
566            .await
567    }
568
569    /// Whether the consumer lets a `Metadata` request create a topic.
570    fn allows_auto_topic_creation(&self) -> bool {
571        matches!(
572            self.client.metadata_topics().scope(),
573            MetadataScope::Topics {
574                allow_auto_topic_creation: true
575            }
576        )
577    }
578
579    /// Send `request` until its topics have no retriable error. Kafka's
580    /// `TopicMetadataFetcher.getTopicMetadata`.
581    async fn topic_metadata(
582        &self,
583        request: MetadataRequest,
584    ) -> Result<HashMap<String, Vec<PartitionInfo>>, ConsumerError> {
585        let deadline = tokio::time::Instant::now() + self.default_api_timeout.to_std();
586        tokio::time::timeout_at(deadline, self.topic_metadata_until(request, deadline))
587            .await
588            .unwrap_or_else(|_| {
589                Err(ConsumerError::timeout(
590                    "Timeout expired while fetching topic metadata".to_owned(),
591                ))
592            })
593    }
594
595    async fn topic_metadata_until(
596        &self,
597        request: MetadataRequest,
598        deadline: tokio::time::Instant,
599    ) -> Result<HashMap<String, Vec<PartitionInfo>>, ConsumerError> {
600        let mut backoff = self.retry_policy.initial_backoff;
601        loop {
602            match self.client.refresh_metadata_with(request.clone()).await {
603                Ok(metadata) => {
604                    if let Some(topics) = topic_partition_infos(&metadata)? {
605                        return Ok(topics);
606                    }
607                }
608                Err(error) if poll::is_transient_transport_error(&error) => {}
609                Err(error) => return Err(error.into()),
610            }
611            if tokio::time::Instant::now() >= deadline {
612                return Err(ConsumerError::timeout(
613                    "Timeout expired while fetching topic metadata".to_owned(),
614                ));
615            }
616            tokio::time::sleep_until((tokio::time::Instant::now() + backoff).min(deadline)).await;
617            backoff = next_backoff(backoff, self.retry_policy.max_backoff);
618        }
619    }
620}
621
622/// Take the rows of `answer` for the partitions that `search` still has.
623fn apply_query_answer(
624    answer: &ListOffsetsResponse,
625    search: &mut HashMap<(String, i32), i64>,
626    found: &mut HashMap<(String, i32), OffsetAndTimestamp>,
627    unauthorized: &mut BTreeSet<String>,
628) {
629    for topic in &answer.topics {
630        for row in &topic.partitions {
631            let key = (topic.name.clone(), row.partition_index);
632            if !search.contains_key(&key) {
633                continue;
634            }
635            match classify_query_row(row.error_code, row.offset, row.timestamp, row.leader_epoch) {
636                QueryRow::Found(offset) => {
637                    search.remove(&key);
638                    found.insert(key, offset);
639                }
640                QueryRow::NotFound => {
641                    search.remove(&key);
642                }
643                QueryRow::Unauthorized => {
644                    unauthorized.insert(topic.name.clone());
645                }
646                QueryRow::Retry => {}
647            }
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use std::{
655        collections::HashMap,
656        sync::{
657            Arc,
658            atomic::{AtomicU16, Ordering},
659        },
660    };
661
662    use krabka_client_core::{Client, MockBroker};
663    use krabka_protocol::{
664        Decode as _, Encode,
665        owned::{
666            api_versions_request,
667            api_versions_response::{ApiVersion, ApiVersionsResponse},
668            list_offsets_request::{
669                self, ListOffsetsPartition, ListOffsetsRequest, ListOffsetsTopic,
670            },
671            list_offsets_response::{
672                ListOffsetsPartitionResponse, ListOffsetsResponse, ListOffsetsTopicResponse,
673            },
674            metadata_request,
675            metadata_response::{
676                MetadataResponse, MetadataResponseBroker, MetadataResponsePartition,
677                MetadataResponseTopic,
678            },
679        },
680    };
681    use krabka_units::{millis, secs};
682    use poll::partition_error_tests::consumer_with_client;
683
684    use super::*;
685
686    /// A `ListOffsets` row of the mock: `(error code, offset, timestamp,
687    /// leader epoch)` for a partition and a searched timestamp.
688    type Rows = fn(i32, i64) -> (i16, i64, i64, i32);
689
690    type SentRequests = Arc<std::sync::Mutex<Vec<ListOffsetsRequest>>>;
691
692    fn encode(response: &impl Encode, version: i16) -> Vec<u8> {
693        let mut buf = bytes::BytesMut::new();
694        response.encode(&mut buf, version).expect("encode");
695        buf.to_vec()
696    }
697
698    fn metadata(port: i32, error_code: i16) -> MetadataResponse {
699        MetadataResponse {
700            brokers: vec![MetadataResponseBroker {
701                node_id: 1,
702                host: "127.0.0.1".into(),
703                port,
704                rack: Some("az-1".into()),
705                ..Default::default()
706            }],
707            topics: vec![MetadataResponseTopic {
708                error_code,
709                name: Some("orders".into()),
710                partitions: (0..2)
711                    .map(|partition_index| MetadataResponsePartition {
712                        partition_index,
713                        leader_id: 1,
714                        leader_epoch: 4,
715                        replica_nodes: vec![1, 2],
716                        isr_nodes: vec![1],
717                        offline_replicas: vec![2],
718                        ..Default::default()
719                    })
720                    .collect(),
721                ..Default::default()
722            }],
723            ..Default::default()
724        }
725    }
726
727    /// A broker that leads `orders-0` and `orders-1`, answers `Metadata` with
728    /// `metadata_error` for the topic, and answers each `ListOffsets`
729    /// partition with `rows`, or not at all for `None`.
730    async fn query_broker(
731        rows: Option<Rows>,
732        metadata_error: i16,
733        list_offsets_max_version: i16,
734        sent: SentRequests,
735    ) -> MockBroker {
736        use bytes::Buf as _;
737        let port = Arc::new(AtomicU16::new(0));
738        let port_in_mock = Arc::clone(&port);
739        let broker = MockBroker::start(move |api_key, version, _corr_id, mut body| {
740            if api_key == api_versions_request::API_KEY {
741                let versions = ApiVersionsResponse {
742                    api_keys: [
743                        (api_versions_request::API_KEY, 3),
744                        (metadata_request::API_KEY, 8),
745                        (list_offsets_request::API_KEY, list_offsets_max_version),
746                    ]
747                    .into_iter()
748                    .map(|(api_key, max_version)| ApiVersion {
749                        api_key,
750                        min_version: 0,
751                        max_version,
752                        ..Default::default()
753                    })
754                    .collect(),
755                    ..Default::default()
756                };
757                return Some(encode(&versions, 0));
758            }
759            if api_key == metadata_request::API_KEY {
760                let port = i32::from(port_in_mock.load(Ordering::SeqCst));
761                return Some(encode(&metadata(port, metadata_error), version));
762            }
763            if api_key == list_offsets_request::API_KEY {
764                let client_id_len = usize::try_from(body.get_i16()).expect("client id");
765                body.advance(client_id_len);
766                let request = ListOffsetsRequest::decode(&mut body, version).expect("decode");
767                sent.lock().expect("sent lock").push(request.clone());
768                let rows = rows?;
769                let answer = ListOffsetsResponse {
770                    topics: request
771                        .topics
772                        .iter()
773                        .map(|topic| ListOffsetsTopicResponse {
774                            name: topic.name.clone(),
775                            partitions: topic
776                                .partitions
777                                .iter()
778                                .map(|partition| {
779                                    let (error_code, offset, timestamp, leader_epoch) =
780                                        rows(partition.partition_index, partition.timestamp);
781                                    ListOffsetsPartitionResponse {
782                                        partition_index: partition.partition_index,
783                                        error_code,
784                                        offset,
785                                        timestamp,
786                                        leader_epoch,
787                                        ..Default::default()
788                                    }
789                                })
790                                .collect(),
791                            ..Default::default()
792                        })
793                        .collect(),
794                    ..Default::default()
795                };
796                return Some(encode(&answer, version));
797            }
798            None
799        })
800        .await;
801        port.store(broker.addr.port(), Ordering::SeqCst);
802        broker
803    }
804
805    fn request(isolation_level: i8, timestamps: [i64; 2]) -> ListOffsetsRequest {
806        ListOffsetsRequest {
807            replica_id: -1,
808            isolation_level,
809            topics: vec![ListOffsetsTopic {
810                name: "orders".into(),
811                partitions: timestamps
812                    .into_iter()
813                    .zip(0..)
814                    .map(|(timestamp, partition_index)| ListOffsetsPartition {
815                        partition_index,
816                        current_leader_epoch: 4,
817                        timestamp,
818                        ..Default::default()
819                    })
820                    .collect(),
821                ..Default::default()
822            }],
823            ..Default::default()
824        }
825    }
826
827    /// What one query returned, in a form that the table can compare.
828    #[derive(Debug, PartialEq)]
829    enum Answer {
830        Offsets(HashMap<(String, i32), i64>),
831        Times(HashMap<(String, i32), Option<OffsetAndTimestamp>>),
832        Error(String),
833    }
834
835    #[derive(Clone, Copy, Debug)]
836    enum Query {
837        Beginning,
838        End,
839        Times([i64; 2]),
840    }
841
842    /// Kafka's `beginningOffsets`, `endOffsets` and `offsetsForTimes` send
843    /// `ListOffsets` to the leader with the timestamps -2, -1 or the searched
844    /// ones, and the isolation level of the consumer
845    /// (`OffsetFetcher.fetchOffsetsByTimes`, `OffsetFetcherUtils.handleListOffsetResponse`).
846    #[tokio::test]
847    async fn offset_queries_follow_kafkas_offset_fetcher() {
848        let key = |partition: i32| ("orders".to_string(), partition);
849        let found: Rows = |partition, timestamp| {
850            if timestamp < 0 {
851                (0, i64::from(partition) + 3, -1, -1)
852            } else if partition == 0 {
853                (0, 42, timestamp + 5, 2)
854            } else {
855                (0, -1, -1, -1)
856            }
857        };
858        let unauthorized: Rows = |_, _| (29, -1, -1, -1);
859        let mut actual = Vec::new();
860        let mut wanted = Vec::new();
861        for (name, isolation, query, (rows, metadata_error, list_offsets_max), expected, expected_requests) in [
862            (
863                "beginning offsets",
864                IsolationLevel::ReadUncommitted,
865                Query::Beginning,
866                (Some(found), 0, 5),
867                Answer::Offsets(maplit::hashmap! {key(0) => 3, key(1) => 4}),
868                vec![request(0, [-2, -2])],
869            ),
870            (
871                "end offsets with read committed",
872                IsolationLevel::ReadCommitted,
873                Query::End,
874                (Some(found), 0, 5),
875                Answer::Offsets(maplit::hashmap! {key(0) => 3, key(1) => 4}),
876                vec![request(1, [-1, -1])],
877            ),
878            (
879                "offsets for times",
880                IsolationLevel::ReadUncommitted,
881                Query::Times([1000, 2000]),
882                (Some(found), 0, 5),
883                Answer::Times(maplit::hashmap! {key(0) => Some(OffsetAndTimestamp {
884                            offset: 42,
885                            timestamp: 1005,
886                            leader_epoch: Some(2),
887                        }), key(1) => None}),
888                vec![request(0, [1000, 2000])],
889            ),
890            (
891                "negative timestamp",
892                IsolationLevel::ReadUncommitted,
893                Query::Times([-1, 2000]),
894                (Some(found), 0, 5),
895                Answer::Error(
896                    "invalid argument: The target time for partition orders-0 is -1. The target time cannot be negative."
897                        .into(),
898                ),
899                vec![],
900            ),
901            (
902                "unauthorized topic",
903                IsolationLevel::ReadUncommitted,
904                Query::Beginning,
905                (Some(unauthorized), 0, 5),
906                Answer::Error("not authorized to access topics: [orders]".into()),
907                vec![request(0, [-2, -2])],
908            ),
909            (
910                "metadata does not authorize the topic",
911                IsolationLevel::ReadUncommitted,
912                Query::Beginning,
913                (Some(found), 29, 5),
914                Answer::Error("not authorized to access topics: [orders]".into()),
915                vec![],
916            ),
917            (
918                "metadata names the topic invalid",
919                IsolationLevel::ReadUncommitted,
920                Query::End,
921                (Some(found), 17, 5),
922                Answer::Error("topic 'orders' is invalid".into()),
923                vec![],
924            ),
925            (
926                "a silent leader ends by the API timeout",
927                IsolationLevel::ReadUncommitted,
928                Query::Times([1000, 2000]),
929                (None, 0, 5),
930                Answer::Error("timeout".into()),
931                vec![request(0, [1000, 2000])],
932            ),
933            (
934                "offsets for times on a broker with ListOffsets v1",
935                IsolationLevel::ReadUncommitted,
936                Query::Times([1000, 2000]),
937                (Some(found), 0, 1),
938                Answer::Times(maplit::hashmap! {key(0) => Some(OffsetAndTimestamp {
939                            offset: 42,
940                            timestamp: 1005,
941                            leader_epoch: None,
942                        }), key(1) => None}),
943                vec![ListOffsetsRequest {
944                    replica_id: -1,
945                    topics: vec![ListOffsetsTopic {
946                        name: "orders".into(),
947                        partitions: [1000, 2000]
948                            .into_iter()
949                            .zip(0..)
950                            .map(|(timestamp, partition_index)| ListOffsetsPartition {
951                                partition_index,
952                                current_leader_epoch: -1,
953                                timestamp,
954                                ..Default::default()
955                            })
956                            .collect(),
957                        ..Default::default()
958                    }],
959                    ..Default::default()
960                }],
961            ),
962        ] {
963            let sent = SentRequests::default();
964            let broker =
965                query_broker(rows, metadata_error, list_offsets_max, Arc::clone(&sent)).await;
966            let client = Client::builder()
967                .bootstrap(broker.addr.to_string())
968                .request_timeout(secs(30))
969                .build()
970                .await
971                .expect("client");
972            let mut consumer = consumer_with_client(client);
973            consumer.isolation_level = isolation;
974            consumer.default_api_timeout = millis(500);
975            let partitions = [key(0), key(1)];
976            let answer = match query {
977                Query::Beginning => consumer.beginning_offsets(&partitions).await.map(Answer::Offsets),
978                Query::End => consumer.end_offsets(&partitions).await.map(Answer::Offsets),
979                Query::Times(timestamps) => consumer
980                    .offsets_for_times(&partitions.iter().cloned().zip(timestamps).collect())
981                    .await
982                    .map(Answer::Times),
983            }
984            .unwrap_or_else(|error| match error {
985                // The message names the elapsed time.
986                ConsumerError::Timeout { .. } => Answer::Error("timeout".into()),
987                error => Answer::Error(error.to_string()),
988            });
989            broker.stop();
990            let mut requests = sent.lock().expect("sent lock").clone();
991            for request in &mut requests {
992                for topic in &mut request.topics {
993                    topic.partitions.sort_by_key(|partition| partition.partition_index);
994                }
995            }
996            actual.push((name, answer, requests));
997            wanted.push((name, expected, expected_requests));
998        }
999        assert2::assert!(actual == wanted);
1000    }
1001
1002    /// Kafka's `TopicMetadataFetcher.getTopicMetadata`: an unauthorized topic
1003    /// and an invalid topic fail, an unknown topic is left out, a retriable
1004    /// error sends the request again, and a replica that the broker list does
1005    /// not name is `Node(id, "", -1)`.
1006    #[test]
1007    fn topic_partition_infos_follow_kafkas_topic_metadata_fetcher() {
1008        let node_1 = Node {
1009            id: 1,
1010            host: "127.0.0.1".into(),
1011            port: 9092,
1012            rack: Some("az-1".into()),
1013        };
1014        let node_2 = Node {
1015            id: 2,
1016            host: String::new(),
1017            port: -1,
1018            rack: None,
1019        };
1020        let orders = (0..2)
1021            .map(|partition| PartitionInfo {
1022                topic: "orders".into(),
1023                partition,
1024                leader: Some(node_1.clone()),
1025                replicas: vec![node_1.clone(), node_2.clone()],
1026                in_sync_replicas: vec![node_1.clone()],
1027                offline_replicas: vec![node_2.clone()],
1028            })
1029            .collect::<Vec<_>>();
1030        let mut actual = Vec::new();
1031        let mut wanted = Vec::new();
1032        for (name, error_code, expected) in [
1033            (
1034                "no error",
1035                0,
1036                Ok(Some(
1037                    maplit::hashmap! {"orders".to_string() => orders.clone()},
1038                )),
1039            ),
1040            ("unknown topic", 3, Ok(Some(HashMap::new()))),
1041            ("leader not available", 5, Ok(None)),
1042            (
1043                "unauthorized",
1044                29,
1045                Err("not authorized to access topics: [orders]".to_string()),
1046            ),
1047            (
1048                "invalid topic",
1049                17,
1050                Err("topic 'orders' is invalid".to_string()),
1051            ),
1052        ] {
1053            let result = topic_partition_infos(&metadata(9092, error_code))
1054                .map_err(|error| error.to_string());
1055            actual.push((name, result));
1056            wanted.push((name, expected));
1057        }
1058        assert2::assert!(actual == wanted);
1059    }
1060
1061    /// Kafka's `currentLag` is the end offset of the last fetch minus the
1062    /// position, empty when one of them is not known, and it throws for an
1063    /// unassigned partition (`SubscriptionState.partitionLag`).
1064    /// Kafka's `listTopics` asks for every topic with a null topic list
1065    /// (`MetadataRequest.Builder.allTopics`). `MetadataRequest::default()`
1066    /// carries the schema default, an empty list, which asks for none.
1067    #[tokio::test]
1068    async fn list_topics_asks_for_every_topic() {
1069        use bytes::Buf as _;
1070        let seen: Arc<std::sync::Mutex<Vec<MetadataRequest>>> = Arc::default();
1071        let seen_in_mock = Arc::clone(&seen);
1072        let port = Arc::new(AtomicU16::new(0));
1073        let port_in_mock = Arc::clone(&port);
1074        let broker = MockBroker::start(move |api_key, version, _corr_id, mut body| {
1075            if api_key == api_versions_request::API_KEY {
1076                let versions = ApiVersionsResponse {
1077                    api_keys: [
1078                        (api_versions_request::API_KEY, 3),
1079                        (metadata_request::API_KEY, 8),
1080                    ]
1081                    .into_iter()
1082                    .map(|(api_key, max_version)| ApiVersion {
1083                        api_key,
1084                        min_version: 0,
1085                        max_version,
1086                        ..Default::default()
1087                    })
1088                    .collect(),
1089                    ..Default::default()
1090                };
1091                return Some(encode(&versions, 0));
1092            }
1093            if api_key == metadata_request::API_KEY {
1094                let client_id_len = usize::try_from(body.get_i16()).expect("client id");
1095                body.advance(client_id_len);
1096                let request = MetadataRequest::decode(&mut body, version).expect("decode");
1097                seen_in_mock.lock().expect("seen lock").push(request);
1098                let port = i32::from(port_in_mock.load(Ordering::SeqCst));
1099                return Some(encode(&metadata(port, 0), version));
1100            }
1101            None
1102        })
1103        .await;
1104        port.store(broker.addr.port(), Ordering::SeqCst);
1105        let client = Client::builder()
1106            .bootstrap(broker.addr.to_string())
1107            .build()
1108            .await
1109            .expect("client");
1110        let consumer = consumer_with_client(client);
1111
1112        let topics = consumer.list_topics().await.expect("list topics");
1113
1114        assert2::check!(topics.keys().collect::<Vec<_>>() == vec!["orders"]);
1115        let seen = seen.lock().expect("seen lock");
1116        assert2::check!(seen.last() == Some(&krabka_client_core::all_topics_request()));
1117        assert2::check!(
1118            krabka_client_core::all_topics_request()
1119                == MetadataRequest {
1120                    topics: None,
1121                    allow_auto_topic_creation: true,
1122                    ..Default::default()
1123                }
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn current_lag_follows_kafkas_partition_lag() {
1129        let broker = query_broker(Some(|_, _| (0, 0, 0, 0)), 0, 5, SentRequests::default()).await;
1130        let client = Client::builder()
1131            .bootstrap(broker.addr.to_string())
1132            .build()
1133            .await
1134            .expect("client");
1135        let consumer = consumer_with_client(client);
1136        let key = ("orders".to_string(), 0);
1137        let mut actual = Vec::new();
1138        let mut wanted = Vec::new();
1139        for (name, partition, position, end, seek, awaiting_validation, expected) in [
1140            ("known", 0, 5, Some(12), None, false, Ok(Some(7))),
1141            ("after a seek", 0, 5, Some(12), Some(8), false, Ok(Some(4))),
1142            ("no end offset", 0, 5, None, None, false, Ok(None)),
1143            ("awaiting validation", 0, 5, Some(12), None, true, Ok(None)),
1144            (
1145                "awaiting reset",
1146                0,
1147                poll::END_SENTINEL,
1148                Some(12),
1149                None,
1150                false,
1151                Ok(None),
1152            ),
1153            (
1154                "unassigned",
1155                9,
1156                5,
1157                Some(12),
1158                None,
1159                false,
1160                Err("no current assignment for partition orders-9".to_string()),
1161            ),
1162        ] {
1163            consumer
1164                .positions
1165                .lock()
1166                .await
1167                .entry(key.clone())
1168                .or_default()
1169                .awaiting_validation = awaiting_validation;
1170            consumer
1171                .next_offsets
1172                .lock()
1173                .await
1174                .insert(key.clone(), position);
1175            let mut ends = consumer.end_offsets.lock().await;
1176            ends.clear();
1177            if let Some(end) = end {
1178                ends.insert(key.clone(), end);
1179            }
1180            drop(ends);
1181            if let Some(offset) = seek {
1182                consumer.seek("orders", 0, offset).await.expect("seek");
1183            }
1184            let lag = consumer
1185                .current_lag("orders", partition)
1186                .await
1187                .map_err(|error| error.to_string());
1188            actual.push((name, lag));
1189            wanted.push((name, expected));
1190        }
1191        broker.stop();
1192        assert2::assert!(actual == wanted);
1193    }
1194
1195    #[test]
1196    fn classify_query_row_follows_handle_list_offset_response() {
1197        let actual = [
1198            (0, 7, 100, 3),
1199            (0, 7, 100, -1),
1200            (0, -1, -1, -1),
1201            (43, -1, -1, -1),
1202            (29, -1, -1, -1),
1203            (6, -1, -1, -1),
1204        ]
1205        .map(|(error_code, offset, timestamp, epoch)| {
1206            classify_query_row(error_code, offset, timestamp, epoch)
1207        });
1208        let found = |leader_epoch| {
1209            QueryRow::Found(OffsetAndTimestamp {
1210                offset: 7,
1211                timestamp: 100,
1212                leader_epoch,
1213            })
1214        };
1215        assert2::assert!(
1216            actual
1217                == [
1218                    found(Some(3)),
1219                    found(None),
1220                    QueryRow::NotFound,
1221                    QueryRow::NotFound,
1222                    QueryRow::Unauthorized,
1223                    QueryRow::Retry,
1224                ]
1225        );
1226    }
1227}