Skip to main content

krabka_barrier/
verify.rs

1//! Proves that the markers a cut names are really in the log.
2//!
3//! A cut is a list of offsets, and it is worth exactly as much as the markers
4//! behind it. This reads the log at each of those offsets and checks that the
5//! batch there is a barrier control batch carrying this group and this epoch.
6//!
7//! The read cannot go through [`krabka_client_core::fetch_partition`], because
8//! that drops control batches the way every Kafka consumer does, which is the
9//! whole reason a marker is invisible in the first place. So this sends a raw
10//! `Fetch` and decodes the batches itself.
11//!
12//! The client negotiates the highest `Fetch` version the broker speaks, and
13//! from version 13 the request names a topic by id rather than by name. The
14//! metadata read that finds each leader therefore also carries the topic id
15//! back, so the fetch is correct at any version.
16
17use {
18    crate::report::described_error,
19    krabka_log::parse_barrier_marker,
20    krabka_protocol::{
21        krabka::barrier as api,
22        owned::{
23            fetch_request::{FetchPartition, FetchRequest, FetchTopic},
24            metadata_request::{MetadataRequest, MetadataRequestTopic},
25        },
26        primitives::uuid::Uuid,
27        records::{RecordBatch, RecordsPayload},
28    },
29    std::collections::BTreeMap,
30};
31
32/// One offset that does not hold the marker the cut claims.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Mismatch {
35    /// The topic the cut named.
36    pub topic: String,
37    /// The partition the cut named.
38    pub partition: i32,
39    /// The offset the cut named.
40    pub offset: i64,
41    /// Why the log does not agree.
42    pub reason: String,
43}
44
45/// What one verify found.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct VerifyOutcome {
48    /// The epoch that was verified.
49    pub epoch: i64,
50    /// Every offset that holds the marker it should, as topic, partition and
51    /// offset.
52    pub checked: Vec<(String, i32, i64)>,
53    /// Every offset that does not.
54    pub mismatches: Vec<Mismatch>,
55}
56
57/// Read the log at each of a cut's offsets and report what is there.
58///
59/// # Errors
60///
61/// Returns a message when the broker refuses the cut read, when the group has
62/// no cut at `epoch`, or when a `Fetch` cannot be sent at all. A partition the
63/// log disagrees with is a [`Mismatch`] in the outcome rather than an error:
64/// the point of the command is to report every one of them, not to stop at the
65/// first.
66pub async fn verify(
67    client: &krabka_client_core::Client,
68    group: &str,
69    epoch: i64,
70) -> Result<VerifyOutcome, String> {
71    let cut = fetch_cut(client, group, epoch).await?;
72    let mut outcome = VerifyOutcome {
73        epoch,
74        checked: Vec::new(),
75        mismatches: Vec::new(),
76    };
77
78    for topic in &cut.topics {
79        let ResolvedTopic { topic_id, leaders } = resolve_topic(client, &topic.topic).await?;
80        for partition in &topic.partitions {
81            let Some(&leader) = leaders.get(&partition.partition) else {
82                outcome.mismatches.push(Mismatch {
83                    topic: topic.topic.clone(),
84                    partition: partition.partition,
85                    offset: partition.offset,
86                    reason: "the partition has no leader, so the log cannot be read".to_owned(),
87                });
88                continue;
89            };
90            let batch = fetch_batch_at(
91                client,
92                leader,
93                &topic.topic,
94                topic_id,
95                partition.partition,
96                partition.offset,
97            )
98            .await;
99            match batch
100                .and_then(|batch| check_batch(batch.as_ref(), group, epoch, partition.offset))
101            {
102                Ok(()) => outcome.checked.push((
103                    topic.topic.clone(),
104                    partition.partition,
105                    partition.offset,
106                )),
107                Err(reason) => outcome.mismatches.push(Mismatch {
108                    topic: topic.topic.clone(),
109                    partition: partition.partition,
110                    offset: partition.offset,
111                    reason,
112                }),
113            }
114        }
115    }
116    Ok(outcome)
117}
118
119/// Read the one cut a group holds at `epoch`.
120async fn fetch_cut(
121    client: &krabka_client_core::Client,
122    group: &str,
123    epoch: i64,
124) -> Result<api::BarrierCut, String> {
125    let response = client
126        .send(api::ListBarrierCutsRequest {
127            group: group.to_owned(),
128            from_epoch: epoch,
129            max_results: 1,
130            ..api::ListBarrierCutsRequest::default()
131        })
132        .await
133        .map_err(|e| format!("cannot read the cuts of {group}: {e}"))?;
134    if response.error_code != 0 {
135        return Err(format!(
136            "cannot read the cuts of {group}: {}",
137            described_error(response.error_code, response.error_message.as_deref())
138        ));
139    }
140    response
141        .cuts
142        .into_iter()
143        .find(|cut| cut.epoch == epoch)
144        .ok_or_else(|| format!("{group} has no retained cut at epoch {epoch}"))
145}
146
147/// The id of one topic, and the current leader of each of its partitions.
148struct ResolvedTopic {
149    topic_id: Uuid,
150    /// The leader's broker id, by partition.
151    leaders: BTreeMap<i32, i32>,
152}
153
154/// Look up one topic's [`ResolvedTopic`].
155///
156/// The id is what a modern `Fetch` names the topic by, and the leaders are
157/// where each fetch has to go.
158async fn resolve_topic(
159    client: &krabka_client_core::Client,
160    topic: &str,
161) -> Result<ResolvedTopic, String> {
162    let response = client
163        .send(MetadataRequest {
164            topics: Some(vec![MetadataRequestTopic {
165                name: Some(topic.to_owned()),
166                ..MetadataRequestTopic::default()
167            }]),
168            ..MetadataRequest::default()
169        })
170        .await
171        .map_err(|e| format!("cannot read the metadata of {topic}: {e}"))?;
172    let found = response
173        .topics
174        .iter()
175        .find(|t| t.name.as_deref() == Some(topic))
176        .ok_or_else(|| format!("the cluster does not know the topic {topic}"))?;
177    if found.error_code != 0 {
178        return Err(format!(
179            "the cluster refused the metadata of {topic} with error {}",
180            found.error_code
181        ));
182    }
183    Ok(ResolvedTopic {
184        topic_id: found.topic_id,
185        leaders: found
186            .partitions
187            .iter()
188            .map(|p| (p.partition_index, p.leader_id))
189            .collect(),
190    })
191}
192
193/// Fetch the batch that starts at `offset`, if the leader serves one.
194async fn fetch_batch_at(
195    client: &krabka_client_core::Client,
196    leader: i32,
197    topic: &str,
198    topic_id: Uuid,
199    partition: i32,
200    offset: i64,
201) -> Result<Option<RecordBatch>, String> {
202    let response = client
203        .broker(leader)
204        .send(FetchRequest {
205            replica_id: -1,
206            max_wait_ms: 1_000,
207            min_bytes: 1,
208            max_bytes: 1 << 20,
209            topics: vec![FetchTopic {
210                topic: topic.to_owned(),
211                topic_id,
212                partitions: vec![FetchPartition {
213                    partition,
214                    fetch_offset: offset,
215                    partition_max_bytes: 1 << 20,
216                    ..FetchPartition::default()
217                }],
218                ..FetchTopic::default()
219            }],
220            ..FetchRequest::default()
221        })
222        .await
223        .map_err(|e| format!("cannot fetch {topic}-{partition} at {offset}: {e}"))?;
224
225    let Some(part) = response
226        .responses
227        .iter()
228        .flat_map(|t| t.partitions.iter())
229        .find(|p| p.partition_index == partition)
230    else {
231        return Err(format!(
232            "the leader served no response for {topic}-{partition}"
233        ));
234    };
235    if part.error_code != 0 {
236        return Err(format!(
237            "the leader refused the fetch of {topic}-{partition} with error {}",
238            part.error_code
239        ));
240    }
241    Ok(part
242        .records
243        .as_ref()
244        .and_then(RecordsPayload::as_v2)
245        .and_then(|batches| {
246            batches
247                .iter()
248                .find(|batch| batch.base_offset == offset)
249                .cloned()
250        }))
251}
252
253/// Decide whether one fetched batch is the marker a cut claims.
254///
255/// Pulled out of the read so the reasons can be driven directly. Every branch
256/// names something that would make a cut a lie: no batch there at all, an
257/// ordinary data batch, a transaction marker, or a marker from another group or
258/// another epoch.
259fn check_batch(
260    batch: Option<&RecordBatch>,
261    group: &str,
262    epoch: i64,
263    offset: i64,
264) -> Result<(), String> {
265    let Some(batch) = batch else {
266        return Err(format!("the log holds no batch starting at {offset}"));
267    };
268    if !batch.attributes.is_control_batch() {
269        return Err(format!(
270            "the batch at {offset} is a data batch, not a control batch"
271        ));
272    }
273    let Some(record) = batch.records.first() else {
274        return Err(format!("the control batch at {offset} holds no record"));
275    };
276    let marker = parse_barrier_marker(record)
277        .map_err(|e| format!("the control batch at {offset} is not a barrier marker: {e}"))?;
278    if marker.group != group {
279        return Err(format!(
280            "the marker at {offset} belongs to group {}, not {group}",
281            marker.group
282        ));
283    }
284    if marker.epoch != epoch {
285        return Err(format!(
286            "the marker at {offset} carries epoch {}, not {epoch}",
287            marker.epoch
288        ));
289    }
290    Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295    use {
296        super::*,
297        assert2::check,
298        bytes::Bytes,
299        krabka_broker::testing,
300        krabka_protocol::records::{Attributes, Record, RecordBatch},
301    };
302
303    /// A barrier marker fixture made with the broker's production encoder.
304    fn marker_batch(group: &str, epoch: i64, offset: i64) -> RecordBatch {
305        testing::barrier_marker_batch(group, epoch, 1_724_500_000_000, offset)
306            .expect("valid marker fixture")
307    }
308
309    #[test]
310    fn a_matching_marker_verifies() {
311        let batch = marker_batch("orders-cut", 7, 42);
312        check!(check_batch(Some(&batch), "orders-cut", 7, 42).is_ok());
313    }
314
315    enum RejectedBatchCase {
316        Missing,
317        Data,
318        EmptyControl,
319        OtherGroup,
320        OtherEpoch,
321    }
322
323    /// Every one of these would make a published cut a lie, so each has to be
324    /// reported rather than passed over.
325    #[yare::parameterized(
326        missing_batch = { RejectedBatchCase::Missing, "holds no batch" },
327        data_batch = { RejectedBatchCase::Data, "not a control batch" },
328        empty_control_batch = { RejectedBatchCase::EmptyControl, "holds no record" },
329        another_group = { RejectedBatchCase::OtherGroup, "belongs to group" },
330        another_epoch = { RejectedBatchCase::OtherEpoch, "carries epoch" },
331    )]
332    fn a_cut_that_the_log_does_not_back_is_reported(case: RejectedBatchCase, expected: &str) {
333        let data = RecordBatch {
334            base_offset: 42,
335            records: vec![Record::default()],
336            ..RecordBatch::default()
337        };
338        let empty_control = RecordBatch {
339            base_offset: 42,
340            attributes: Attributes::default().with_control(true),
341            ..RecordBatch::default()
342        };
343        let other_group = marker_batch("payments-cut", 7, 42);
344        let other_epoch = marker_batch("orders-cut", 8, 42);
345        let batch = match case {
346            RejectedBatchCase::Missing => None,
347            RejectedBatchCase::Data => Some(&data),
348            RejectedBatchCase::EmptyControl => Some(&empty_control),
349            RejectedBatchCase::OtherGroup => Some(&other_group),
350            RejectedBatchCase::OtherEpoch => Some(&other_epoch),
351        };
352        let reason = check_batch(batch, "orders-cut", 7, 42).expect_err("rejects this batch");
353        check!(reason.contains(expected), "got {reason}");
354    }
355
356    #[test]
357    fn the_matching_marker_is_accepted() {
358        let good = marker_batch("orders-cut", 7, 42);
359        check!(check_batch(Some(&good), "orders-cut", 7, 42).is_ok());
360    }
361
362    /// A transaction marker is a control batch too, and its key parses to a
363    /// different control type, so it must not pass as a barrier.
364    #[test]
365    fn a_transaction_marker_is_not_a_barrier_marker() {
366        let mut key = Vec::new();
367        key.extend_from_slice(&0i16.to_be_bytes());
368        key.extend_from_slice(&1i16.to_be_bytes());
369        let commit = RecordBatch {
370            base_offset: 42,
371            attributes: Attributes::default().with_control(true),
372            records: vec![Record {
373                key: Some(Bytes::from(key)),
374                value: Some(Bytes::from_static(&[0, 0, 0, 0])),
375                ..Record::default()
376            }],
377            ..RecordBatch::default()
378        };
379        let reason = check_batch(Some(&commit), "orders-cut", 7, 42).expect_err("rejects");
380        check!(reason.contains("not a barrier marker"), "got {reason}");
381    }
382}