1use {
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#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Mismatch {
35 pub topic: String,
37 pub partition: i32,
39 pub offset: i64,
41 pub reason: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct VerifyOutcome {
48 pub epoch: i64,
50 pub checked: Vec<(String, i32, i64)>,
53 pub mismatches: Vec<Mismatch>,
55}
56
57pub 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
119async 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
147struct ResolvedTopic {
149 topic_id: Uuid,
150 leaders: BTreeMap<i32, i32>,
152}
153
154async 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
193async 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
253fn 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 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 #[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 #[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}