1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, bon::Builder)]
30pub struct ListShareGroupOffsetsSpec {
31 pub topic_partitions: Option<Vec<(String, i32)>>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct SharePartitionOffsetInfo {
40 pub start_offset: i64,
41 pub leader_epoch: Option<i32>,
43 pub lag: Option<i64>,
46}
47
48pub type ShareGroupOffsets = BTreeMap<(String, i32), Option<SharePartitionOffsetInfo>>;
51
52fn 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
89fn 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 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 #[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 #[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}