Skip to main content

krabka_client_admin/
share_group_offsets.rs

1//! Batched share-group offset listing:
2//! [`AdminClient::list_share_group_offsets`].
3//!
4//! The call finds the coordinator of every group with one batched
5//! `FindCoordinator` request and sends one `DescribeShareGroupOffsets`
6//! request to each coordinator with every group that it coordinates, as
7//! Apache Kafka's `ListShareGroupOffsetsHandler` does with its
8//! `CoordinatorStrategy`. A request to a coordinator that
9//! fails or loses its connection finds the coordinator again.
10
11use std::collections::BTreeMap;
12
13use krabka_protocol::owned::{
14    describe_share_group_offsets_request::{
15        DescribeShareGroupOffsetsRequest, DescribeShareGroupOffsetsRequestGroup,
16        DescribeShareGroupOffsetsRequestTopic,
17    },
18    describe_share_group_offsets_response::DescribeShareGroupOffsetsResponse,
19};
20
21use crate::{
22    AdminClient, KafkaError,
23    group_coordinators::{GroupOutcome, group_error_outcome},
24    group_descriptions::request_error_outcomes,
25};
26
27/// The partitions to list for one share group, as Apache Kafka's
28/// `ListShareGroupOffsetsSpec` holds them.
29#[derive(Debug, Clone, Default, PartialEq, Eq, bon::Builder)]
30pub struct ListShareGroupOffsetsSpec {
31    /// The partitions to list, or `None` for every partition that the group
32    /// has an offset for.
33    pub topic_partitions: Option<Vec<(String, i32)>>,
34}
35
36/// One partition's share-group offset, as Apache Kafka's
37/// `SharePartitionOffsetInfo` holds it.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct SharePartitionOffsetInfo {
40    pub start_offset: i64,
41    /// `None` when the coordinator gives a negative epoch.
42    pub leader_epoch: Option<i32>,
43    /// `None` when the coordinator gives a negative lag, as
44    /// `DescribeShareGroupOffsets` v0 always does.
45    pub lag: Option<i64>,
46}
47
48/// The offsets of one share group: each answered partition, with `None` for a
49/// partition that has no start offset.
50pub type ShareGroupOffsets = BTreeMap<(String, i32), Option<SharePartitionOffsetInfo>>;
51
52/// The `DescribeShareGroupOffsets` request for `keys`, as Kafka's
53/// `ListShareGroupOffsetsHandler.buildBatchedRequest` builds it.
54fn describe_share_group_offsets_request(
55    keys: &[String],
56    specs: &BTreeMap<String, ListShareGroupOffsetsSpec>,
57) -> DescribeShareGroupOffsetsRequest {
58    DescribeShareGroupOffsetsRequest {
59        groups: keys
60            .iter()
61            .map(|group_id| DescribeShareGroupOffsetsRequestGroup {
62                group_id: group_id.clone(),
63                topics: specs
64                    .get(group_id)
65                    .and_then(|spec| spec.topic_partitions.as_ref())
66                    .map(|partitions| {
67                        let mut topics = BTreeMap::<&str, Vec<i32>>::new();
68                        for (topic, partition) in partitions {
69                            topics.entry(topic).or_default().push(*partition);
70                        }
71                        topics
72                            .into_iter()
73                            .map(
74                                |(topic_name, partitions)| DescribeShareGroupOffsetsRequestTopic {
75                                    topic_name: topic_name.to_owned(),
76                                    partitions,
77                                    ..Default::default()
78                                },
79                            )
80                            .collect()
81                    }),
82                ..Default::default()
83            })
84            .collect(),
85        ..Default::default()
86    }
87}
88
89/// The outcome of each group of `keys` in one `DescribeShareGroupOffsets`
90/// answer, as Kafka's `ListShareGroupOffsetsHandler.handleResponse` reads it.
91///
92/// A group error retries as `handleGroupError` does. A partition with an
93/// error code gives no entry, a negative start offset gives `None`, and a
94/// negative leader epoch or lag gives `None` for that field. A group that the
95/// answer omits completes with no partitions, as the handler completes each
96/// key without a group error.
97fn share_group_offsets_outcomes(
98    keys: &[String],
99    response: DescribeShareGroupOffsetsResponse,
100) -> BTreeMap<String, GroupOutcome<ShareGroupOffsets>> {
101    let mut outcomes = keys
102        .iter()
103        .map(|key| {
104            (
105                key.clone(),
106                GroupOutcome::Done(Ok(ShareGroupOffsets::new())),
107            )
108        })
109        .collect::<BTreeMap<_, _>>();
110    for group in response.groups {
111        let Some(outcome) = outcomes.get_mut(&group.group_id) else {
112            continue;
113        };
114        if group.error_code != 0 {
115            *outcome = group_error_outcome(group.error_code, group.error_message, &[]);
116            continue;
117        }
118        let GroupOutcome::Done(Ok(offsets)) = outcome else {
119            continue;
120        };
121        for topic in group.topics {
122            for partition in topic.partitions {
123                if partition.error_code != 0 {
124                    tracing::debug!(
125                        topic = topic.topic_name,
126                        partition = partition.partition_index,
127                        error_code = partition.error_code,
128                        "skipping a share partition with an error"
129                    );
130                    continue;
131                }
132                let info = (partition.start_offset >= 0).then_some(SharePartitionOffsetInfo {
133                    start_offset: partition.start_offset,
134                    leader_epoch: (partition.leader_epoch >= 0).then_some(partition.leader_epoch),
135                    lag: (partition.lag >= 0).then_some(partition.lag),
136                });
137                offsets.insert((topic.topic_name.clone(), partition.partition_index), info);
138            }
139        }
140    }
141    outcomes
142}
143
144impl AdminClient {
145    /// Lists the start offsets of share groups with
146    /// `DescribeShareGroupOffsets`, as Apache Kafka's `listShareGroupOffsets`
147    /// does with its `ListShareGroupOffsetsHandler`.
148    ///
149    /// The call finds the coordinator of every group with one batched
150    /// `FindCoordinator` request and sends one `DescribeShareGroupOffsets`
151    /// request to each coordinator with every group that it coordinates, each
152    /// with the partitions of its [`ListShareGroupOffsetsSpec`].
153    /// `COORDINATOR_LOAD_IN_PROGRESS` (14) sends a group to the same
154    /// coordinator again, and `COORDINATOR_NOT_AVAILABLE` (15) and
155    /// `NOT_COORDINATOR` (16) find its coordinator again. A lost
156    /// connection finds the coordinator again. The call waits with Kafka's
157    /// backoff between rounds and stops at `default.api.timeout.ms` (60 s),
158    /// where each pending group gets `REQUEST_TIMED_OUT` (7).
159    ///
160    /// Each group's offsets hold every answered partition without an error.
161    /// A partition with no start offset maps to `None`.
162    ///
163    /// # Errors
164    /// The call itself does not fail. Each group gets its own [`KafkaError`],
165    /// such as `GROUP_AUTHORIZATION_FAILED` (30) or `REQUEST_TIMED_OUT` (7)
166    /// at the deadline.
167    pub async fn list_share_group_offsets(
168        &self,
169        specs: &BTreeMap<String, ListShareGroupOffsetsSpec>,
170    ) -> BTreeMap<String, Result<ShareGroupOffsets, KafkaError>> {
171        let groups = specs.keys().map(String::as_str).collect::<Vec<_>>();
172        self.call_group_coordinators(
173            "describeShareGroupOffsets",
174            &groups,
175            |coordinator, keys| async move {
176                let connection = self.connect_coordinator(&coordinator).await?;
177                let request = describe_share_group_offsets_request(&keys, specs);
178                Ok(match connection.send(request).await {
179                    Ok(response) => share_group_offsets_outcomes(&keys, response),
180                    Err(error) => request_error_outcomes(&keys, &error.into()),
181                })
182            },
183        )
184        .await
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use std::sync::{Arc, Mutex};
191
192    use assert2::assert;
193    use krabka_client_core::MockReply;
194    use krabka_protocol::owned::{
195        describe_share_group_offsets_request,
196        describe_share_group_offsets_response::{
197            DescribeShareGroupOffsetsResponseGroup, DescribeShareGroupOffsetsResponsePartition,
198            DescribeShareGroupOffsetsResponseTopic,
199        },
200    };
201
202    use super::*;
203    use crate::{
204        group_coordinators::{
205            kafka_error,
206            test_support::{Lookup, group_cluster},
207        },
208        partition_leaders::test_support::{ApiVersionRange, decode_request, encode_response},
209    };
210
211    fn partition(
212        partition_index: i32,
213        start_offset: i64,
214        leader_epoch: i32,
215        lag: i64,
216        error_code: i16,
217    ) -> DescribeShareGroupOffsetsResponsePartition {
218        DescribeShareGroupOffsetsResponsePartition {
219            partition_index,
220            start_offset,
221            leader_epoch,
222            lag,
223            error_code,
224            ..Default::default()
225        }
226    }
227
228    #[test]
229    fn request_names_the_partitions_of_each_spec() {
230        let specs = maplit::btreemap! {
231            "all".to_owned() => ListShareGroupOffsetsSpec::builder().build(),
232            "some".to_owned() => ListShareGroupOffsetsSpec::builder()
233        .topic_partitions(vec![
234            ("orders".to_owned(), 2),
235            ("audit".to_owned(), 0),
236            ("orders".to_owned(), 0),
237        ])
238        .build(),
239        };
240        let keys = ["all".to_owned(), "some".to_owned()];
241        assert!(
242            describe_share_group_offsets_request(&keys, &specs)
243                == DescribeShareGroupOffsetsRequest {
244                    groups: vec![
245                        DescribeShareGroupOffsetsRequestGroup {
246                            group_id: "all".to_owned(),
247                            topics: None,
248                            ..Default::default()
249                        },
250                        DescribeShareGroupOffsetsRequestGroup {
251                            group_id: "some".to_owned(),
252                            topics: Some(vec![
253                                DescribeShareGroupOffsetsRequestTopic {
254                                    topic_name: "audit".to_owned(),
255                                    partitions: vec![0],
256                                    ..Default::default()
257                                },
258                                DescribeShareGroupOffsetsRequestTopic {
259                                    topic_name: "orders".to_owned(),
260                                    partitions: vec![2, 0],
261                                    ..Default::default()
262                                },
263                            ]),
264                            ..Default::default()
265                        },
266                    ],
267                    ..Default::default()
268                }
269        );
270    }
271
272    /// Kafka's `ListShareGroupOffsetsHandler.handleResponse` skips a
273    /// partition with an error, maps a negative start offset to `null` and a
274    /// negative epoch or lag to `Optional.empty()`, and retries the group
275    /// errors of `handleGroupError`.
276    #[test]
277    fn outcomes_read_each_group_as_kafka_does() {
278        let keys = ["good", "loading", "moved", "denied", "absent"].map(str::to_owned);
279        let response = DescribeShareGroupOffsetsResponse {
280            groups: vec![
281                DescribeShareGroupOffsetsResponseGroup {
282                    group_id: "good".to_owned(),
283                    topics: vec![DescribeShareGroupOffsetsResponseTopic {
284                        topic_name: "orders".to_owned(),
285                        partitions: vec![
286                            partition(0, 41, 3, 5, 0),
287                            partition(1, 7, -1, -1, 0),
288                            partition(2, -1, 0, 0, 0),
289                            partition(3, 9, 1, 1, 3),
290                        ],
291                        ..Default::default()
292                    }],
293                    ..Default::default()
294                },
295                DescribeShareGroupOffsetsResponseGroup {
296                    group_id: "loading".to_owned(),
297                    error_code: 14,
298                    ..Default::default()
299                },
300                DescribeShareGroupOffsetsResponseGroup {
301                    group_id: "moved".to_owned(),
302                    error_code: 16,
303                    ..Default::default()
304                },
305                DescribeShareGroupOffsetsResponseGroup {
306                    group_id: "denied".to_owned(),
307                    error_code: 30,
308                    error_message: Some("denied".to_owned()),
309                    ..Default::default()
310                },
311            ],
312            ..Default::default()
313        };
314        let info = |start_offset, leader_epoch, lag| {
315            Some(SharePartitionOffsetInfo {
316                start_offset,
317                leader_epoch,
318                lag,
319            })
320        };
321        assert!(
322            share_group_offsets_outcomes(&keys, response)
323                == maplit::btreemap! {
324                    "good".to_owned() => GroupOutcome::Done(Ok(maplit::btreemap! {
325                        ("orders".to_owned(), 0) => info(41, Some(3), Some(5)),
326                        ("orders".to_owned(), 1) => info(7, None, None),
327                        ("orders".to_owned(), 2) => None,
328                    })),
329                    "loading".to_owned() => GroupOutcome::Retry("COORDINATOR_LOAD_IN_PROGRESS".to_owned()),
330                    "moved".to_owned() => GroupOutcome::Unmap("NOT_COORDINATOR".to_owned()),
331                    "denied".to_owned() => GroupOutcome::Done(Err(kafka_error(30, Some("denied".to_owned())))),
332                    "absent".to_owned() => GroupOutcome::Done(Ok(BTreeMap::new())),
333                }
334        );
335    }
336
337    /// Two groups on one coordinator share one `DescribeShareGroupOffsets`
338    /// request, and a group that is loading is sent again alone.
339    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
340    async fn list_share_group_offsets_batches_the_groups_of_a_coordinator() {
341        let loading_answers = Arc::new(Mutex::new(0_usize));
342        let cluster = group_cluster()
343            .count(1)
344            .apis(vec![
345                ApiVersionRange::builder()
346                    .api_key(describe_share_group_offsets_request::API_KEY)
347                    .max_version(1)
348                    .build(),
349            ])
350            .lookup(|_, _| Lookup::At(0))
351            .answer(move |_, api_key, version, body, _| {
352                if api_key != describe_share_group_offsets_request::API_KEY {
353                    return MockReply::Silent;
354                }
355                let request: DescribeShareGroupOffsetsRequest = decode_request(body, version, true);
356                let groups = request
357                    .groups
358                    .into_iter()
359                    .map(|group| {
360                        let mut loading = loading_answers.lock().expect("answers lock");
361                        let error_code = if group.group_id == "loading" && *loading == 0 {
362                            *loading += 1;
363                            14
364                        } else {
365                            0
366                        };
367                        drop(loading);
368                        DescribeShareGroupOffsetsResponseGroup {
369                            topics: if error_code == 0 {
370                                vec![DescribeShareGroupOffsetsResponseTopic {
371                                    topic_name: "orders".to_owned(),
372                                    partitions: vec![partition(0, 41, 3, 5, 0)],
373                                    ..Default::default()
374                                }]
375                            } else {
376                                Vec::new()
377                            },
378                            group_id: group.group_id,
379                            error_code,
380                            ..Default::default()
381                        }
382                    })
383                    .collect();
384                MockReply::Respond(encode_response(
385                    &DescribeShareGroupOffsetsResponse {
386                        groups,
387                        ..Default::default()
388                    },
389                    version,
390                    true,
391                ))
392            })
393            .call()
394            .await;
395        let admin = cluster.admin(krabka_units::secs(5)).await;
396        let specs = maplit::btreemap! {
397            "ready".to_owned() => ListShareGroupOffsetsSpec::builder().build(),
398            "loading".to_owned() => ListShareGroupOffsetsSpec::builder().build(),
399        };
400
401        let result = admin.list_share_group_offsets(&specs).await;
402
403        let seen = cluster.stop();
404        let requested = seen
405            .requests
406            .iter()
407            .map(|request| {
408                let body: DescribeShareGroupOffsetsRequest =
409                    decode_request(&request.body, request.version, true);
410                (
411                    request.version,
412                    body.groups
413                        .into_iter()
414                        .map(|group| group.group_id)
415                        .collect::<Vec<_>>(),
416                )
417            })
418            .collect::<Vec<_>>();
419        let offsets = Ok(maplit::btreemap! {
420            ("orders".to_owned(), 0) => Some(SharePartitionOffsetInfo {
421            start_offset: 41,
422            leader_epoch: Some(3),
423            lag: Some(5),
424        }),
425        });
426        assert!(
427            (result, requested)
428                == (
429                    maplit::btreemap! {
430                        "loading".to_owned() => offsets.clone(),
431                        "ready".to_owned() => offsets,
432                    },
433                    vec![
434                        (1, vec!["loading".to_owned(), "ready".to_owned()]),
435                        (1, vec!["loading".to_owned()]),
436                    ],
437                )
438        );
439    }
440}