Skip to main content

krabka_backup/
offsets.rs

1//! Committed consumer-group offsets: the one restore input that is not a file
2//! on a node.
3//!
4//! `__consumer_offsets` is compacted and internal, so it is never tiered and a
5//! KIP-405 archive does not hold it. A restore therefore rebuilds the log and
6//! nothing that points into it, and every group starts again from
7//! `auto.offset.reset`. What can be copied is the coordinator's answer: the
8//! committed offset of every group, read with `ListGroups` and `OffsetFetch`
9//! while the cluster is up, and written back with `OffsetCommit` once a
10//! restored cluster is.
11//!
12//! The write-back is Kafka's `Admin.alterConsumerGroupOffsets`, the call
13//! `kafka-consumer-groups --reset-offsets --execute` makes: a simple-consumer
14//! commit, with an empty `member_id` and generation `-1`, that the coordinator
15//! accepts for a group with no live members. A group with live members is
16//! fenced, which is the correct answer: an offset must not move under a
17//! consumer that is reading from it. The admin client retries the coordinator
18//! codes Kafka's `AlterConsumerGroupOffsetsHandler` retries, so a freshly
19//! booted broker that is still creating `__consumer_offsets` is waited for
20//! rather than reported as a refusal.
21
22use {
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/// One group's committed offset in one partition.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct CommittedOffset {
36    /// Topic the offset was committed for.
37    pub topic: String,
38    /// Partition index within that topic.
39    pub partition: i32,
40    /// The committed offset: the next offset the group would read.
41    pub offset: i64,
42}
43
44/// One group, with every partition it has a committed offset for.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct GroupOffsets {
47    /// The group id.
48    pub group: String,
49    /// Its committed offsets, in `(topic, partition)` order.
50    pub offsets: Vec<CommittedOffset>,
51}
52
53/// Version of `group-offsets.json`, its required top-level `"version"` field.
54///
55/// Part of the 1.x on-disk contract: a 1.x `krabka-backup` reads every offsets
56/// file that any earlier 1.x build wrote.
57pub const GROUP_OFFSETS_VERSION: i16 = 0;
58
59/// The captured offsets of every group in one cluster.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct GroupOffsetsFile {
62    /// Always [`GROUP_OFFSETS_VERSION`] when this build writes it.
63    pub version: i16,
64    /// One entry per group that had at least one committed offset.
65    pub groups: Vec<GroupOffsets>,
66}
67
68/// An empty file of the version this build writes.
69impl 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    /// Decode an offsets file, refusing a missing or unknown `"version"`.
80    ///
81    /// # Errors
82    ///
83    /// [`BackupError::UnsupportedVersion`] for a file with no version or one
84    /// other than [`GROUP_OFFSETS_VERSION`], and [`BackupError::Json`] for
85    /// bytes that are not an offsets file. `context` names the object in
86    /// either error.
87    pub fn from_slice(bytes: &[u8], context: &str) -> Result<Self, BackupError> {
88        decode_versioned(bytes, context, GROUP_OFFSETS_VERSION)
89    }
90
91    /// How many `(group, topic, partition)` offsets the file holds.
92    #[must_use]
93    pub fn offset_count(&self) -> usize {
94        self.groups.iter().map(|group| group.offsets.len()).sum()
95    }
96}
97
98/// Turn one `OffsetFetch` answer into the captured shape.
99///
100/// The answer arrives as a map, so the ordering is already `(topic, partition)`
101/// and the captured file is byte-stable across two captures of an unchanged
102/// cluster. That is what lets an operator diff two captures.
103#[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/// One group's captured offsets, keyed the way the admin client commits them.
119#[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/// Every partition the coordinator refused, as `topic-partition: NAME (code)`.
129///
130/// A commit is per-partition on the wire, so a partial refusal is possible and
131/// silence about it would report a restore that put back fewer offsets than it
132/// claims.
133#[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
148/// How many offsets one group's commit put back, or why it put back none.
149///
150/// The admin client retries `COORDINATOR_LOAD_IN_PROGRESS`,
151/// `COORDINATOR_NOT_AVAILABLE`, `NOT_COORDINATOR` and `REBALANCE_IN_PROGRESS`
152/// until Kafka's `default.api.timeout.ms` runs out, so a timeout means the
153/// group's coordinator never became ready, not that the cluster refused.
154///
155/// # Errors
156///
157/// [`BackupError::Cluster`] when the call failed or timed out, or when the
158/// coordinator refused any partition.
159pub 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    /// The exact bytes this build writes for one group of [`captured`]. A
306    /// change here is a change to the 1.x capture format.
307    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}