1use {
23 crate::{error::BackupError, manifest::decode_versioned},
24 krabka_client_admin::{
25 AdminError,
26 groups::{ConsumerGroupOffsetOutcome, GroupOffsets as AdminGroupOffsets},
27 },
28 krabka_client_core::ClientError,
29 serde::{Deserialize, Serialize},
30 std::collections::BTreeMap,
31};
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct CommittedOffset {
36 pub topic: String,
38 pub partition: i32,
40 pub offset: i64,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct GroupOffsets {
47 pub group: String,
49 pub offsets: Vec<CommittedOffset>,
51}
52
53pub const GROUP_OFFSETS_VERSION: i16 = 0;
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct GroupOffsetsFile {
62 pub version: i16,
64 pub groups: Vec<GroupOffsets>,
66}
67
68impl Default for GroupOffsetsFile {
70 fn default() -> Self {
71 Self {
72 version: GROUP_OFFSETS_VERSION,
73 groups: Vec::new(),
74 }
75 }
76}
77
78impl GroupOffsetsFile {
79 pub fn from_slice(bytes: &[u8], context: &str) -> Result<Self, BackupError> {
88 decode_versioned(bytes, context, GROUP_OFFSETS_VERSION)
89 }
90
91 #[must_use]
93 pub fn offset_count(&self) -> usize {
94 self.groups.iter().map(|group| group.offsets.len()).sum()
95 }
96}
97
98#[must_use]
104pub fn group_offsets(group: &str, fetched: &BTreeMap<(String, i32), i64>) -> GroupOffsets {
105 GroupOffsets {
106 group: group.to_owned(),
107 offsets: fetched
108 .iter()
109 .map(|((topic, partition), offset)| CommittedOffset {
110 topic: topic.clone(),
111 partition: *partition,
112 offset: *offset,
113 })
114 .collect(),
115 }
116}
117
118#[must_use]
120pub fn admin_offsets(offsets: &GroupOffsets) -> AdminGroupOffsets {
121 offsets
122 .offsets
123 .iter()
124 .map(|offset| ((offset.topic.clone(), offset.partition), offset.offset))
125 .collect()
126}
127
128#[must_use]
134pub fn commit_refusals(outcomes: &[ConsumerGroupOffsetOutcome]) -> Vec<String> {
135 outcomes
136 .iter()
137 .filter_map(|outcome| {
138 outcome.error.as_ref().map(|error| {
139 format!(
140 "{}-{}: {} ({})",
141 outcome.topic, outcome.partition, error.name, error.code
142 )
143 })
144 })
145 .collect()
146}
147
148pub fn commit_outcome(
160 offsets: &GroupOffsets,
161 result: Result<Vec<ConsumerGroupOffsetOutcome>, AdminError>,
162) -> Result<usize, BackupError> {
163 let group = &offsets.group;
164 let outcomes = result.map_err(|error| match error {
165 AdminError::Transport(ClientError::Timeout(_)) => BackupError::Cluster(format!(
166 "OffsetCommit for {group}: {error}: the group coordinator did not become \
167 available. A broker creates and loads `__consumer_offsets` on the first \
168 coordinator lookup; check the broker log, for example for \
169 INVALID_REPLICATION_FACTOR"
170 )),
171 error => BackupError::Cluster(format!("OffsetCommit for {group}: {error}")),
172 })?;
173 let refusals = commit_refusals(&outcomes);
174 if !refusals.is_empty() {
175 return Err(BackupError::Cluster(format!(
176 "OffsetCommit for {group}: {}",
177 refusals.join("; ")
178 )));
179 }
180 Ok(offsets.offsets.len())
181}
182
183#[cfg(test)]
184mod tests {
185 use {
186 super::{
187 CommittedOffset, GROUP_OFFSETS_VERSION, GroupOffsets, GroupOffsetsFile, admin_offsets,
188 commit_outcome, commit_refusals, group_offsets,
189 },
190 crate::error::BackupError,
191 assert2::check,
192 krabka_client_admin::{AdminError, KafkaError, groups::ConsumerGroupOffsetOutcome},
193 krabka_client_core::ClientError,
194 std::collections::BTreeMap,
195 };
196
197 fn captured() -> GroupOffsets {
198 GroupOffsets {
199 group: "orders-consumers".to_owned(),
200 offsets: vec![
201 CommittedOffset {
202 topic: "orders".to_owned(),
203 partition: 0,
204 offset: 12,
205 },
206 CommittedOffset {
207 topic: "orders".to_owned(),
208 partition: 1,
209 offset: 7,
210 },
211 CommittedOffset {
212 topic: "payments".to_owned(),
213 partition: 0,
214 offset: 3,
215 },
216 ],
217 }
218 }
219
220 #[test]
221 fn a_fetched_map_becomes_offsets_in_topic_and_partition_order() {
222 let fetched = BTreeMap::from([
223 (("payments".to_owned(), 0), 3),
224 (("orders".to_owned(), 1), 7),
225 (("orders".to_owned(), 0), 12),
226 ]);
227 check!(group_offsets("orders-consumers", &fetched) == captured());
228 }
229
230 #[test]
231 fn the_commit_carries_every_captured_partition() {
232 check!(
233 admin_offsets(&captured())
234 == BTreeMap::from([
235 (("orders".to_owned(), 0), 12),
236 (("orders".to_owned(), 1), 7),
237 (("payments".to_owned(), 0), 3),
238 ])
239 );
240 }
241
242 fn outcome(partition: i32, error: Option<(i16, &'static str)>) -> ConsumerGroupOffsetOutcome {
243 ConsumerGroupOffsetOutcome {
244 topic: "orders".to_owned(),
245 partition,
246 error: error.map(|(code, name)| KafkaError {
247 code,
248 name,
249 message: None,
250 }),
251 }
252 }
253
254 #[yare::parameterized(
255 all_accepted = { vec![outcome(0, None), outcome(1, None)], &[] },
256 one_refused = {
257 vec![outcome(0, None), outcome(1, Some((25, "UNKNOWN_MEMBER_ID")))],
258 &["orders-1: UNKNOWN_MEMBER_ID (25)"],
259 },
260 every_refused = {
261 vec![
262 outcome(0, Some((30, "GROUP_AUTHORIZATION_FAILED"))),
263 outcome(1, Some((30, "GROUP_AUTHORIZATION_FAILED"))),
264 ],
265 &[
266 "orders-0: GROUP_AUTHORIZATION_FAILED (30)",
267 "orders-1: GROUP_AUTHORIZATION_FAILED (30)",
268 ],
269 },
270 )]
271 fn only_the_refused_partitions_are_reported(
272 outcomes: Vec<ConsumerGroupOffsetOutcome>,
273 expected: &[&str],
274 ) {
275 check!(commit_refusals(&outcomes) == expected);
276 }
277
278 #[yare::parameterized(
279 committed = { Ok(vec![outcome(0, None), outcome(1, None)]), Ok(3) },
280 refused = {
281 Ok(vec![outcome(0, None), outcome(1, Some((25, "UNKNOWN_MEMBER_ID")))]),
282 Err("cluster: OffsetCommit for orders-consumers: orders-1: UNKNOWN_MEMBER_ID (25)"),
283 },
284 coordinator_never_ready = {
285 Err(AdminError::Transport(ClientError::Timeout(krabka_units::secs(60)))),
286 Err("cluster: OffsetCommit for orders-consumers: client-core: request timed out \
287 after 1m: the group coordinator did not become available. A broker creates \
288 and loads `__consumer_offsets` on the first coordinator lookup; check the \
289 broker log, for example for INVALID_REPLICATION_FACTOR"),
290 },
291 call_failed = {
292 Err(AdminError::Transport(ClientError::Server { error_code: 30 })),
293 Err("cluster: OffsetCommit for orders-consumers: client-core: protocol error from \
294 server: 30"),
295 },
296 )]
297 fn a_group_commit_reports_its_count_or_why_it_committed_nothing(
298 result: Result<Vec<ConsumerGroupOffsetOutcome>, AdminError>,
299 expected: Result<usize, &str>,
300 ) {
301 let actual = commit_outcome(&captured(), result).map_err(|error| error.to_string());
302 check!(actual == expected.map_err(str::to_owned));
303 }
304
305 const GOLDEN_OFFSETS: &str = concat!(
308 r#"{"version":0,"groups":[{"group":"orders-consumers","offsets":["#,
309 r#"{"topic":"orders","partition":0,"offset":12},"#,
310 r#"{"topic":"orders","partition":1,"offset":7},"#,
311 r#"{"topic":"payments","partition":0,"offset":3}]}]}"#,
312 );
313
314 #[test]
315 fn an_offsets_file_encodes_to_the_golden_bytes_and_decodes_back() {
316 let file = GroupOffsetsFile {
317 version: GROUP_OFFSETS_VERSION,
318 groups: vec![captured()],
319 };
320 check!(serde_json::to_string(&file).unwrap() == GOLDEN_OFFSETS);
321 check!(
322 GroupOffsetsFile::from_slice(GOLDEN_OFFSETS.as_bytes(), "group-offsets.json").unwrap()
323 == file
324 );
325 check!(GroupOffsetsFile::default().version == GROUP_OFFSETS_VERSION);
326 }
327
328 #[yare::parameterized(
329 pre_1_0 = { "pre-1.0 offsets file", r#"{"groups":[]}"#, None },
330 future = { "future version", r#"{"version":1,"groups":[]}"#, Some("1") },
331 non_numeric = {
332 "version that is not a number",
333 r#"{"version":"0","groups":[]}"#,
334 Some("\"0\""),
335 },
336 )]
337 fn an_offsets_file_with_a_missing_or_unknown_version_is_refused(
338 name: &str,
339 json: &str,
340 found: Option<&str>,
341 ) {
342 let error =
343 GroupOffsetsFile::from_slice(json.as_bytes(), "group-offsets.json").expect_err(name);
344 assert2::assert!(
345 let BackupError::UnsupportedVersion {
346 found: actual,
347 expected: GROUP_OFFSETS_VERSION,
348 ..
349 } = &error,
350 "case {name}: {error}"
351 );
352 check!(actual.as_deref() == found, "case {name}");
353 }
354
355 #[test]
356 fn the_offsets_file_counts_every_partition_of_every_group() {
357 let file = GroupOffsetsFile {
358 version: GROUP_OFFSETS_VERSION,
359 groups: vec![captured(), GroupOffsets {
360 group: "empty".to_owned(),
361 offsets: Vec::new(),
362 }],
363 };
364 check!(file.offset_count() == 3);
365 }
366}