Skip to main content

krabka_client_consumer/
partition_state.rs

1//! Per-partition state that the application reads and changes: pause and
2//! resume, the fetch position and the committed offset.
3//!
4//! These are Kafka's `KafkaConsumer.pause`, `resume`, `paused`, `position` and
5//! `committed`.
6
7use std::collections::{HashMap, HashSet};
8
9use krabka_units::convert::TimeExt as _;
10
11use crate::{
12    commit::OffsetAndMetadata, consumer::Consumer, coordinator::CoordinatorRetryPolicy,
13    error::ConsumerError, offset_wire, poll::is_reset_sentinel,
14};
15
16/// Paused partitions, with the ownership of each partition at the pause.
17///
18/// Kafka keeps the pause in the `TopicPartitionState` of the partition. A
19/// rebalance that keeps the partition keeps its state, and an assignment that
20/// adds the partition again creates a new state that is not paused
21/// (`SubscriptionState.assignFromSubscribed`). The ownership id changes in the
22/// same cases, so a pause is in effect only while the ownership id is the one
23/// of the pause.
24pub(crate) type PausedPartitions = std::sync::Mutex<HashMap<(String, i32), u64>>;
25
26impl Consumer {
27    /// Stop fetching from `partitions`. Kafka's `KafkaConsumer.pause`.
28    ///
29    /// `poll` does not return records of a paused partition and does not fetch
30    /// it. The consumer stays in the group. Records that `poll` fetched before
31    /// the pause come after [`resume`](Self::resume). A rebalance that keeps
32    /// the partition keeps the pause.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`ConsumerError::NoCurrentAssignment`] if the consumer does not
37    /// own a partition. The call then pauses no partition.
38    pub async fn pause(&self, partitions: &[(String, i32)]) -> Result<(), ConsumerError> {
39        let ownership_ids = self.commit_identity.lock().await.ownership_ids.clone();
40        let mut paused = Vec::with_capacity(partitions.len());
41        for partition in partitions {
42            let Some(ownership_id) = ownership_ids.get(partition) else {
43                return Err(no_current_assignment(partition));
44            };
45            paused.push((partition.clone(), *ownership_id));
46        }
47        self.paused
48            .lock()
49            .unwrap_or_else(std::sync::PoisonError::into_inner)
50            .extend(paused);
51        Ok(())
52    }
53
54    /// Fetch from `partitions` again. Kafka's `KafkaConsumer.resume`.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`ConsumerError::NoCurrentAssignment`] if the consumer does not
59    /// own a partition. The call then resumes no partition.
60    pub async fn resume(&self, partitions: &[(String, i32)]) -> Result<(), ConsumerError> {
61        let ownership_ids = self.commit_identity.lock().await.ownership_ids.clone();
62        if let Some(partition) = partitions
63            .iter()
64            .find(|partition| !ownership_ids.contains_key(*partition))
65        {
66            return Err(no_current_assignment(partition));
67        }
68        let mut paused = self
69            .paused
70            .lock()
71            .unwrap_or_else(std::sync::PoisonError::into_inner);
72        for partition in partitions {
73            paused.remove(partition);
74        }
75        Ok(())
76    }
77
78    /// The paused partitions that the consumer owns. Kafka's
79    /// `KafkaConsumer.paused`.
80    pub async fn paused(&self) -> HashSet<(String, i32)> {
81        let ownership_ids = self.commit_identity.lock().await.ownership_ids.clone();
82        self.paused_of(&ownership_ids)
83    }
84
85    /// The partitions whose pause is in effect for `ownership_ids`.
86    pub(crate) fn paused_of(
87        &self,
88        ownership_ids: &HashMap<(String, i32), u64>,
89    ) -> HashSet<(String, i32)> {
90        let mut paused = self
91            .paused
92            .lock()
93            .unwrap_or_else(std::sync::PoisonError::into_inner);
94        paused.retain(|partition, ownership_id| ownership_ids.get(partition) == Some(ownership_id));
95        paused.keys().cloned().collect()
96    }
97
98    /// The offset of the next record that `poll` fetches for `(topic,
99    /// partition)`. Kafka's `KafkaConsumer.position`.
100    ///
101    /// When the partition waits for an offset reset or for a leader epoch
102    /// validation, the call sends `ListOffsets` or `OffsetForLeaderEpoch` and
103    /// waits for the position, for at most `default_api_timeout`.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`ConsumerError::NoCurrentAssignment`] if the consumer does not
108    /// own the partition, [`ConsumerError::Timeout`] if the position is not
109    /// known before `default_api_timeout`, or the error of a request.
110    pub async fn position(
111        &self,
112        topic: impl Into<String>,
113        partition: i32,
114    ) -> Result<i64, ConsumerError> {
115        let key = (topic.into(), partition);
116        let deadline = tokio::time::Instant::now() + self.default_api_timeout.to_std();
117        let wait = async {
118            loop {
119                if !self.assigned.lock().await.contains(&key) {
120                    return Err(no_current_assignment(&key));
121                }
122                if let Some(offset) = self.valid_position(&key).await {
123                    return Ok(offset);
124                }
125                self.update_fetch_positions(None, Some(deadline)).await?;
126                if self.valid_position(&key).await.is_none() {
127                    tokio::time::sleep(self.retry_policy.initial_backoff).await;
128                }
129            }
130        };
131        // The timeout bounds the requests too, as Kafka's `position` passes
132        // its timer to `updateFetchPositions` and `client.poll`.
133        tokio::time::timeout_at(deadline, wait)
134            .await
135            .unwrap_or_else(|_| {
136                Err(ConsumerError::timeout(format!(
137                    "the position for partition {}-{} could not be determined",
138                    key.0, key.1
139                )))
140            })
141    }
142
143    /// The position of `key` when it is valid: Kafka's
144    /// `SubscriptionState.validPosition`. A position that waits for a reset or
145    /// for validation is not valid.
146    async fn valid_position(&self, key: &(String, i32)) -> Option<i64> {
147        let offsets = self.next_offsets.lock().await;
148        let positions = self.positions.lock().await;
149        let offset = *offsets.get(key)?;
150        let awaiting_validation = positions
151            .get(key)
152            .is_some_and(|position| position.awaiting_validation);
153        (!is_reset_sentinel(offset) && !awaiting_validation).then_some(offset)
154    }
155
156    /// The committed offsets of `partitions` in the group. Kafka's
157    /// `KafkaConsumer.committed`.
158    ///
159    /// A partition without a committed offset maps to `None`. The partitions
160    /// do not need to be assigned to this consumer.
161    ///
162    /// # Errors
163    ///
164    /// Returns the error of the `OffsetFetch` request after the retries that
165    /// Kafka's consumer does, for at most `default_api_timeout`.
166    pub async fn committed(
167        &self,
168        partitions: &[(String, i32)],
169    ) -> Result<HashMap<(String, i32), Option<OffsetAndMetadata>>, ConsumerError> {
170        let deadline = tokio::time::Instant::now() + self.default_api_timeout.to_std();
171        self.committed_until(partitions, deadline).await
172    }
173
174    /// The committed offsets of `partitions`, within `deadline`.
175    pub(crate) async fn committed_until(
176        &self,
177        partitions: &[(String, i32)],
178        deadline: tokio::time::Instant,
179    ) -> Result<HashMap<(String, i32), Option<OffsetAndMetadata>>, ConsumerError> {
180        self.require_group_id()?;
181        if partitions.is_empty() {
182            return Ok(HashMap::new());
183        }
184        let mut by_topic: HashMap<String, Vec<i32>> = HashMap::new();
185        for (topic, partition) in partitions {
186            by_topic.entry(topic.clone()).or_default().push(*partition);
187        }
188        let request = offset_wire::build_offset_fetch(&self.group_id, &by_topic);
189        let fetch = crate::coordinator::send_offset_fetch(
190            &self.client,
191            &self.group_id,
192            &self.coordinator_id,
193            &request,
194            CoordinatorRetryPolicy {
195                timeout: deadline.saturating_duration_since(tokio::time::Instant::now()),
196                ..self.retry_policy
197            },
198        );
199        // The deadline bounds each request too. Kafka's
200        // `fetchCommittedOffsets` gives up when its timer expires.
201        let response = tokio::time::timeout_at(deadline, fetch)
202            .await
203            .map_err(|_| {
204                ConsumerError::timeout(
205                    "the last committed offsets could not be determined".to_owned(),
206                )
207            })??;
208        let mut committed = offset_wire::parse_committed_offsets(&response);
209        committed.retain(|partition, _| partitions.contains(partition));
210        for partition in partitions {
211            committed.entry(partition.clone()).or_insert(None);
212        }
213        Ok(committed)
214    }
215}
216
217fn no_current_assignment((topic, partition): &(String, i32)) -> ConsumerError {
218    ConsumerError::NoCurrentAssignment {
219        topic: topic.clone(),
220        partition: *partition,
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use std::collections::HashSet;
227
228    use krabka_client_core::{Client, MockBroker};
229    use krabka_protocol::{
230        Encode,
231        owned::{
232            api_versions_request,
233            api_versions_response::{ApiVersion, ApiVersionsResponse},
234            offset_fetch_request,
235            offset_fetch_response::{
236                OffsetFetchResponse, OffsetFetchResponsePartition, OffsetFetchResponseTopic,
237            },
238        },
239    };
240    use krabka_units::secs;
241
242    use crate::{commit::OffsetAndMetadata, poll::partition_error_tests::consumer_with_client};
243
244    fn encode(response: &impl Encode, version: i16) -> Vec<u8> {
245        let mut buf = bytes::BytesMut::new();
246        response.encode(&mut buf, version).expect("encode");
247        buf.to_vec()
248    }
249
250    /// A coordinator that answers `OffsetFetch` v5 with `rows`:
251    /// `(partition, committed offset, leader epoch, metadata, error code)` of
252    /// `orders`.
253    async fn offset_fetch_coordinator(rows: Vec<(i32, i64, i32, &'static str, i16)>) -> MockBroker {
254        MockBroker::start(move |api_key, version, _corr_id, _body| {
255            if api_key == api_versions_request::API_KEY {
256                let versions = ApiVersionsResponse {
257                    api_keys: [
258                        (api_versions_request::API_KEY, 0, 3),
259                        (offset_fetch_request::API_KEY, 5, 5),
260                    ]
261                    .into_iter()
262                    .map(|(api_key, min_version, max_version)| ApiVersion {
263                        api_key,
264                        min_version,
265                        max_version,
266                        ..Default::default()
267                    })
268                    .collect(),
269                    ..Default::default()
270                };
271                return Some(encode(&versions, 0));
272            }
273            if api_key != offset_fetch_request::API_KEY {
274                return None;
275            }
276            let response = OffsetFetchResponse {
277                topics: vec![OffsetFetchResponseTopic {
278                    name: "orders".into(),
279                    partitions: rows
280                        .iter()
281                        .map(
282                            |(
283                                partition_index,
284                                committed_offset,
285                                committed_leader_epoch,
286                                metadata,
287                                error_code,
288                            )| {
289                                OffsetFetchResponsePartition {
290                                    partition_index: *partition_index,
291                                    committed_offset: *committed_offset,
292                                    committed_leader_epoch: *committed_leader_epoch,
293                                    metadata: Some((*metadata).into()),
294                                    error_code: *error_code,
295                                    ..Default::default()
296                                }
297                            },
298                        )
299                        .collect(),
300                    ..Default::default()
301                }],
302                ..Default::default()
303            };
304            Some(encode(&response, version))
305        })
306        .await
307    }
308
309    /// Kafka's `committed` maps a partition without a committed offset to
310    /// `null`, and keeps the leader epoch and metadata of a committed one
311    /// (`ConsumerCoordinator.OffsetFetchResponseHandler`).
312    #[tokio::test]
313    async fn committed_returns_kafkas_offsets_and_metadata() {
314        let mock = offset_fetch_coordinator(vec![
315            (0, 10, 3, "note", 0),
316            (1, -1, -1, "", 0),
317            (3, 7, -1, "", 0),
318        ])
319        .await;
320        let client = Client::builder()
321            .bootstrap(mock.addr.to_string())
322            .request_timeout(secs(30))
323            .build()
324            .await
325            .expect("client");
326        let consumer = consumer_with_client(client);
327        let partitions = [
328            ("orders".to_string(), 0),
329            ("orders".to_string(), 1),
330            ("orders".to_string(), 2),
331        ];
332        let committed = consumer
333            .committed(&partitions)
334            .await
335            .map_err(|error| error.to_string());
336        mock.stop();
337        assert2::assert!(
338            committed
339                == Ok(
340                    maplit::hashmap! {("orders".to_string(), 0) => Some(OffsetAndMetadata {
341                        offset: 10,
342                        leader_epoch: Some(3),
343                        metadata: "note".into(),
344                    }), ("orders".to_string(), 1) => None, ("orders".to_string(), 2) => None}
345                )
346        );
347    }
348
349    /// Kafka's `pause`, `resume` and `paused` act on assigned partitions only,
350    /// and a partition that the consumer owns again after a rebalance is not
351    /// paused (`SubscriptionState.assignFromSubscribed` gives it a new state).
352    #[tokio::test]
353    async fn pause_follows_the_ownership_of_the_partition() {
354        let key = ("orders".to_string(), 0);
355        let mut actual = Vec::new();
356        let mut wanted = Vec::new();
357        for (name, pause, resume, new_ownership, expected_error, expected_paused) in [
358            (
359                "pause",
360                vec![key.clone()],
361                vec![],
362                false,
363                None,
364                vec![key.clone()],
365            ),
366            (
367                "pause and resume",
368                vec![key.clone()],
369                vec![key.clone()],
370                false,
371                None,
372                vec![],
373            ),
374            ("owned again", vec![key.clone()], vec![], true, None, vec![]),
375            (
376                "unowned partition",
377                vec![("orders".to_string(), 9)],
378                vec![],
379                false,
380                Some("no current assignment for partition orders-9"),
381                vec![],
382            ),
383        ] {
384            let mock = offset_fetch_coordinator(vec![]).await;
385            let client = Client::builder()
386                .bootstrap(mock.addr.to_string())
387                .build()
388                .await
389                .expect("client");
390            let consumer = consumer_with_client(client);
391            let mut error = consumer
392                .pause(&pause)
393                .await
394                .err()
395                .map(|error| error.to_string());
396            if let Err(resume_error) = consumer.resume(&resume).await {
397                error.get_or_insert(resume_error.to_string());
398            }
399            if new_ownership {
400                consumer
401                    .commit_identity
402                    .lock()
403                    .await
404                    .ownership_ids
405                    .insert(key.clone(), 2);
406            }
407            let paused = consumer.paused().await;
408            mock.stop();
409            actual.push((name, error, paused));
410            wanted.push((
411                name,
412                expected_error.map(str::to_owned),
413                expected_paused.into_iter().collect::<HashSet<_>>(),
414            ));
415        }
416        assert2::assert!(actual == wanted);
417    }
418
419    /// Kafka's `committed(partitions, timeout)` gives up when the timeout
420    /// expires, also while a request waits for its response.
421    #[tokio::test]
422    async fn committed_returns_by_the_api_timeout_when_the_coordinator_is_silent() {
423        let mock = MockBroker::start(|api_key, _version, _corr_id, _body| {
424            (api_key == api_versions_request::API_KEY).then(|| {
425                encode(
426                    &ApiVersionsResponse {
427                        api_keys: [
428                            (api_versions_request::API_KEY, 0, 3),
429                            (offset_fetch_request::API_KEY, 5, 5),
430                        ]
431                        .into_iter()
432                        .map(|(api_key, min_version, max_version)| ApiVersion {
433                            api_key,
434                            min_version,
435                            max_version,
436                            ..Default::default()
437                        })
438                        .collect(),
439                        ..Default::default()
440                    },
441                    0,
442                )
443            })
444        })
445        .await;
446        let client = Client::builder()
447            .bootstrap(mock.addr.to_string())
448            .request_timeout(secs(30))
449            .build()
450            .await
451            .expect("client");
452        let mut consumer = consumer_with_client(client);
453        consumer.default_api_timeout = krabka_units::millis(200);
454        let started = tokio::time::Instant::now();
455        let committed = consumer
456            .committed(&[("orders".to_string(), 0)])
457            .await
458            .map_err(|error| error.to_string());
459        let in_time = started.elapsed() < std::time::Duration::from_secs(5);
460        mock.stop();
461        assert2::assert!(
462            (committed, in_time)
463                == (
464                    Err("timeout: the last committed offsets could not be determined".to_owned()),
465                    true
466                )
467        );
468    }
469
470    /// Kafka's `seekToEnd` requests a reset and keeps the high watermark of
471    /// the last fetch. A partition that waits for the reset is not at the end.
472    #[tokio::test]
473    async fn seek_to_end_keeps_the_end_offset_and_leaves_the_log_end() {
474        let mock = offset_fetch_coordinator(vec![]).await;
475        let client = Client::builder()
476            .bootstrap(mock.addr.to_string())
477            .build()
478            .await
479            .expect("client");
480        let consumer = consumer_with_client(client);
481        let key = ("orders".to_string(), 0);
482        consumer.next_offsets.lock().await.insert(key.clone(), 12);
483        consumer.end_offsets.lock().await.insert(key.clone(), 12);
484        let before = consumer.at_log_end().await;
485        consumer.seek_to_end(&[]).await.expect("seek to end");
486        let after = consumer.at_log_end().await;
487        let end = consumer.end_offsets.lock().await.get(&key).copied();
488        mock.stop();
489        assert2::assert!((before, after, end) == (true, false, Some(12)));
490    }
491
492    /// Kafka's `assign` keeps the position of a partition that stays
493    /// assigned, and a new partition starts at the committed offset of the
494    /// group, or by `auto.offset.reset` without one
495    /// (`refreshCommittedOffsetsIfNeeded`, `resetInitializingPositions`).
496    #[tokio::test]
497    async fn assign_starts_new_partitions_at_the_committed_offset_or_the_reset() {
498        let mock = offset_fetch_coordinator(vec![(1, 10, 2, "", 0), (2, -1, -1, "", 0)]).await;
499        let client = Client::builder()
500            .bootstrap(mock.addr.to_string())
501            .request_timeout(secs(30))
502            .build()
503            .await
504            .expect("client");
505        let mut consumer = consumer_with_client(client);
506        consumer.subscription = crate::subscription::shared(Vec::new(), None, true);
507        consumer.auto_offset_reset = crate::AutoOffsetReset::Earliest;
508        let key = |partition: i32| ("orders".to_string(), partition);
509        consumer
510            .assign(&[key(0), key(1), key(2)])
511            .await
512            .expect("assign");
513        // The metadata of the poll gave the partition the current leader
514        // epoch before the committed offset came.
515        consumer
516            .positions
517            .lock()
518            .await
519            .entry(key(1))
520            .or_default()
521            .leader_epoch = krabka_ids::LeaderEpoch(5);
522        consumer
523            .resolve_committed_sentinels(None)
524            .await
525            .expect("resolve");
526        let mut offsets: Vec<_> = consumer
527            .next_offsets
528            .lock()
529            .await
530            .clone()
531            .into_iter()
532            .collect();
533        offsets.sort();
534        let identity = consumer.commit_identity.lock().await.clone();
535        let awaiting_validation = consumer
536            .positions
537            .lock()
538            .await
539            .get(&key(1))
540            .is_some_and(|position| position.awaiting_validation);
541        let rejected = consumer
542            .subscribe(["orders"])
543            .await
544            .map_err(|error| error.to_string());
545        mock.stop();
546        assert2::assert!(
547            (
548                offsets,
549                awaiting_validation,
550                identity.generation,
551                identity.member_id,
552                consumer.assignment().await,
553                rejected
554            ) == (
555                vec![(key(0), 5), (key(1), 10), (key(2), crate::poll::BEGINNING_SENTINEL)],
556                true,
557                -1,
558                String::new(),
559                vec![key(0), key(1), key(2)],
560                Err("illegal state: the consumer was built without a subscription, so it has no group membership; build it with subscribe or subscribe_pattern to join a group".to_owned())
561            )
562        );
563    }
564}