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