1use {
13 crate::materialize::SegmentOutcome,
14 serde::Serialize,
15 std::{
16 collections::BTreeMap,
17 fmt::{self, Display, Write},
18 path::PathBuf,
19 },
20 uuid::Uuid,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, clap::ValueEnum, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum ReportFormat {
29 #[default]
31 Text,
32 Json,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct RestoreReport {
39 pub dry_run: bool,
41 pub log_dir: PathBuf,
43 pub cluster_id: Uuid,
45 pub authentication: Option<AuthenticationReport>,
47 pub diskless: Option<DisklessRestoreReport>,
49 pub metadata: MetadataRestoreReport,
51 pub partitions: Vec<PartitionReport>,
53 pub skipped: Vec<SkippedSegment>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct DisklessRestoreReport {
60 pub captured_at_ms: u64,
61 pub source_cutoffs: Vec<i64>,
62 pub partitions: Vec<DisklessPartitionReport>,
63 pub limitations: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct DisklessPartitionReport {
69 pub topic: String,
70 pub partition: i32,
71 pub topic_id: Uuid,
72 pub delete_floor: i64,
73 pub recovery_cutoff: i64,
74 pub objects: u64,
75 pub batches: u64,
76 pub records: u64,
77 pub records_dropped: u64,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct AuthenticationReport {
83 pub partitions: usize,
85 pub manifests: u64,
87 pub objects: u64,
89 pub trusted_keys: usize,
91 pub chain_heads: BTreeMap<String, String>,
93 pub tail_truncation_protected: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct MetadataRestoreReport {
100 pub snapshot: Option<PathBuf>,
102 pub topic_configs: usize,
104 pub access_control_entries: usize,
106 pub client_quotas: usize,
108 pub scram_credentials: usize,
110 pub feature_levels: usize,
112 pub topics_without_configuration: Vec<String>,
116}
117
118impl MetadataRestoreReport {
119 #[must_use]
121 pub fn warning(&self) -> Option<String> {
122 self.snapshot.as_ref().map_or_else(
123 || {
124 Some(format!(
125 "WARNING: no --metadata-snapshot; topic configuration was not recovered for: {}; ACLs, client quotas, SCRAM credentials, and feature levels were not recovered",
126 self.topics_without_configuration.join(", ")
127 ))
128 },
129 |snapshot| {
130 (!self.topics_without_configuration.is_empty()).then(|| {
131 format!(
132 "WARNING: --metadata-snapshot {} does not hold these restored topics \
133 under their archived topic ids, so their configuration was not \
134 recovered: {}. Set their configuration again with kafka-configs",
135 snapshot.display(),
136 self.topics_without_configuration.join(", ")
137 )
138 })
139 },
140 )
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
146pub struct PartitionReport {
147 pub topic: String,
149 pub partition: i32,
151 pub topic_id: Uuid,
153 pub segments: Vec<SegmentOutcome>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
159pub struct SkippedSegment {
160 pub topic: String,
162 pub partition: i32,
164 pub segment_id: Uuid,
166 pub reason: String,
168}
169
170impl RestoreReport {
171 pub fn render(&self, format: ReportFormat) -> serde_json::Result<String> {
181 match format {
182 ReportFormat::Text => Ok(TextReport(self).to_string()),
183 ReportFormat::Json => serde_json::to_string_pretty(self),
184 }
185 }
186
187 fn write_text(&self, out: &mut impl Write) -> fmt::Result {
200 let status = if self.dry_run {
201 "dry run against"
202 } else {
203 "wrote"
204 };
205 writeln!(out, "krabka restore: {status} {}", self.log_dir.display())?;
206 if self.dry_run {
207 writeln!(out, " (dry run: nothing was written to disk)")?;
208 }
209 writeln!(out, "cluster id: {}", self.cluster_id)?;
210
211 if let Some(diskless) = &self.diskless {
212 writeln!(
213 out,
214 "diskless WAL capture: {} partition{}, captured at {} ms",
215 diskless.partitions.len(),
216 plural(diskless.partitions.len() as u64),
217 diskless.captured_at_ms
218 )?;
219 for partition in &diskless.partitions {
220 writeln!(
221 out,
222 " {}-{}: floor {}, restored through offset {} (exclusive); {} batch{}, {} record{} kept, {} dropped",
223 partition.topic,
224 partition.partition,
225 partition.delete_floor,
226 partition.recovery_cutoff,
227 partition.batches,
228 plural(partition.batches),
229 partition.records,
230 plural(partition.records),
231 partition.records_dropped,
232 )?;
233 }
234 for limitation in &diskless.limitations {
235 writeln!(out, "WARNING: {limitation}")?;
236 }
237 }
238
239 if let Some(authentication) = &self.authentication {
240 writeln!(
241 out,
242 "authentication: {} manifest{}, {} object{}, {} partition{}, {} trusted key{}",
243 authentication.manifests,
244 plural(authentication.manifests),
245 authentication.objects,
246 plural(authentication.objects),
247 authentication.partitions,
248 plural(authentication.partitions as u64),
249 authentication.trusted_keys,
250 plural(authentication.trusted_keys as u64),
251 )?;
252 let tail = if authentication.tail_truncation_protected {
253 "tail truncation protected by independently pinned heads"
254 } else {
255 "tail truncation not protected without independently pinned heads"
256 };
257 writeln!(
258 out,
259 "authentication boundary: verified chain tips {}; {tail}",
260 authentication
261 .chain_heads
262 .iter()
263 .map(|(partition, head)| format!("{partition}={head}"))
264 .collect::<Vec<_>>()
265 .join(", ")
266 )?;
267 }
268
269 if let Some(warning) = self.metadata.warning() {
270 writeln!(out, "{warning}")?;
271 }
272 if self.metadata.snapshot.is_some() {
275 writeln!(
276 out,
277 "metadata: {} topic config{}, {} ACL{}, {} client quota{}, {} SCRAM credential{}, {} feature level{}",
278 self.metadata.topic_configs,
279 plural(self.metadata.topic_configs as u64),
280 self.metadata.access_control_entries,
281 plural(self.metadata.access_control_entries as u64),
282 self.metadata.client_quotas,
283 plural(self.metadata.client_quotas as u64),
284 self.metadata.scram_credentials,
285 plural(self.metadata.scram_credentials as u64),
286 self.metadata.feature_levels,
287 plural(self.metadata.feature_levels as u64),
288 )?;
289 }
290
291 let mut current_topic: Option<TopicHeading<'_>> = None;
292 for partition in &self.partitions {
293 let topic_key = TopicHeading {
294 topic: partition.topic.as_str(),
295 topic_id: partition.topic_id,
296 };
297 if current_topic != Some(topic_key) {
298 writeln!(out)?;
299 writeln!(out, "{} (topic id {})", partition.topic, partition.topic_id)?;
300 current_topic = Some(topic_key);
301 }
302 write_partition_line(out, partition)?;
303 }
304
305 if !self.skipped.is_empty() {
306 writeln!(out)?;
307 writeln!(
308 out,
309 "skipped ({} segment{} failed verification):",
310 self.skipped.len(),
311 plural(self.skipped.len() as u64)
312 )?;
313 for segment in &self.skipped {
314 writeln!(
315 out,
316 " {} partition {} segment {}: {}",
317 segment.topic, segment.partition, segment.segment_id, segment.reason
318 )?;
319 }
320 }
321
322 Ok(())
323 }
324}
325
326struct TextReport<'a>(&'a RestoreReport);
328
329impl Display for TextReport<'_> {
330 fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.write_text(out) }
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335struct TopicHeading<'a> {
336 topic: &'a str,
337 topic_id: Uuid,
338}
339
340fn write_partition_line(out: &mut impl Write, partition: &PartitionReport) -> fmt::Result {
347 let (Some(first), Some(last)) = (partition.segments.first(), partition.segments.last()) else {
348 writeln!(
349 out,
350 " partition {}: no segments restored",
351 partition.partition
352 )?;
353 return Ok(());
354 };
355
356 let mut records_kept = 0u64;
357 let mut records_dropped = 0u64;
358 let mut batches_rewritten = 0u64;
359 let mut batches_emptied = 0u64;
360 for segment in &partition.segments {
361 records_kept += segment.records_kept;
362 records_dropped += segment.records_dropped;
363 batches_rewritten += segment.batches_rewritten;
364 batches_emptied += segment.batches_emptied;
365 }
366
367 let segment_count = partition.segments.len() as u64;
368 let mut notes = Vec::new();
369 if batches_rewritten > 0 {
370 notes.push(format!(
371 "{} batch{} rewritten",
372 format_thousands(batches_rewritten),
373 plural(batches_rewritten)
374 ));
375 }
376 if batches_emptied > 0 {
377 notes.push(format!(
378 "{} batch{} emptied",
379 format_thousands(batches_emptied),
380 plural(batches_emptied)
381 ));
382 }
383 let notes = if notes.is_empty() {
384 String::new()
385 } else {
386 format!(" ({})", notes.join(", "))
387 };
388
389 writeln!(
390 out,
391 " partition {}: {} segment{}, offsets [{}, {}], {} records kept, {} dropped{notes}",
392 partition.partition,
393 format_thousands(segment_count),
394 plural(segment_count),
395 first.base_offset,
396 last.end_offset,
397 format_thousands(records_kept),
398 format_thousands(records_dropped),
399 )
400}
401
402fn plural(count: u64) -> &'static str { if count == 1 { "" } else { "s" } }
404
405fn format_thousands(n: u64) -> String {
410 let digits = n.to_string();
411 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
412 for (index, digit) in digits.chars().enumerate() {
413 if index > 0 && (digits.len() - index).is_multiple_of(3) {
414 grouped.push(',');
415 }
416 grouped.push(digit);
417 }
418 grouped
419}
420
421#[cfg(test)]
422mod tests {
423 use {
424 super::{
425 MetadataRestoreReport, PartitionReport, ReportFormat, RestoreReport, SegmentOutcome,
426 SkippedSegment, format_thousands, plural,
427 },
428 assert2::check,
429 krabka_ids::Offset,
430 std::path::PathBuf,
431 uuid::Uuid,
432 };
433
434 #[derive(derive_more::From, derive_more::Into, Clone, Copy, Default)]
435 struct RestoredRecordCount(u64);
436
437 #[derive(derive_more::From, derive_more::Into, Clone, Copy, Default)]
438 struct RestoredBatchCount(u64);
439
440 #[derive(Clone, Copy, krabka_macros::FieldDefaults)]
441 struct SegmentOutcomeSetup {
442 #[default(Offset(0))]
443 base: Offset,
444 #[default(Offset(402))]
445 end: Offset,
446 #[default(RestoredRecordCount(402))]
447 kept: RestoredRecordCount,
448 dropped: RestoredRecordCount,
449 rewritten: RestoredBatchCount,
450 emptied: RestoredBatchCount,
451 }
452
453 #[derive(Clone, Copy, PartialEq, Eq)]
454 enum RestoreMode {
455 Perform,
456 DryRun,
457 }
458
459 #[bon::builder]
460 fn segment(
461 #[builder(into, default = Offset (0))] base: Offset,
462 #[builder(into, default = Offset (402))] end: Offset,
463 #[builder(into, default = RestoredRecordCount (402))] kept: RestoredRecordCount,
464 #[builder(into, default)] dropped: RestoredRecordCount,
465 #[builder(into, default)] rewritten: RestoredBatchCount,
466 #[builder(into, default)] emptied: RestoredBatchCount,
467 ) -> SegmentOutcome {
468 let setup = SegmentOutcomeSetup {
469 base,
470 end,
471 kept,
472 dropped,
473 rewritten,
474 emptied,
475 };
476 let SegmentOutcomeSetup {
477 base,
478 end,
479 kept,
480 dropped,
481 rewritten,
482 emptied,
483 } = setup;
484 SegmentOutcome {
485 segment_id: Uuid::from_u128(u128::from(base.0.unsigned_abs()) + 1),
486 base_offset: base,
487 end_offset: end,
488 batches_kept: 1,
489 batches_rewritten: rewritten.0,
490 batches_emptied: emptied.0,
491 records_kept: kept.0,
492 records_dropped: dropped.0,
493 bytes_written: 1024,
494 }
495 }
496
497 fn sample_report(mode: RestoreMode) -> RestoreReport {
498 RestoreReport {
499 dry_run: mode == RestoreMode::DryRun,
500 log_dir: "/var/lib/krabka/restored".into(),
501 cluster_id: Uuid::from_u128(0xC1_A5_7E_00),
502 authentication: None,
503 diskless: None,
504 metadata: MetadataRestoreReport {
505 snapshot: Some("/backup/metadata.checkpoint".into()),
506 topic_configs: 2,
507 access_control_entries: 1,
508 client_quotas: 1,
509 scram_credentials: 1,
510 feature_levels: 2,
511 topics_without_configuration: vec![],
512 },
513 partitions: vec![
514 PartitionReport {
515 topic: "orders".to_owned(),
516 partition: 0,
517 topic_id: Uuid::from_u128(0x0001),
518 segments: vec![
519 segment()
520 .end(4999)
521 .kept(4995)
522 .dropped(5)
523 .rewritten(1)
524 .call(),
525 segment()
526 .base(5000)
527 .end(12489)
528 .kept(7485)
529 .dropped(4)
530 .emptied(1)
531 .call(),
532 ],
533 },
534 PartitionReport {
535 topic: "orders".to_owned(),
536 partition: 1,
537 topic_id: Uuid::from_u128(0x0001),
538 segments: vec![segment().end(8021).kept(8015).dropped(6).call()],
539 },
540 PartitionReport {
541 topic: "orders-archive".to_owned(),
542 partition: 0,
543 topic_id: Uuid::from_u128(0x0002),
544 segments: vec![segment().call()],
545 },
546 ],
547 skipped: vec![SkippedSegment {
548 topic: "orders-2".to_owned(),
549 partition: 3,
550 segment_id: Uuid::from_u128(0xBAD),
551 reason: "checksum mismatch in batch at offset 900".to_owned(),
552 }],
553 }
554 }
555
556 #[test]
557 fn default_report_format_is_text() {
558 check!(ReportFormat::default() == ReportFormat::Text);
559 }
560
561 #[test]
562 fn format_thousands_groups_digits() {
563 let cases = [
564 (0u64, "0"),
565 (5, "5"),
566 (999, "999"),
567 (1000, "1,000"),
568 (12480, "12,480"),
569 (1_234_567, "1,234,567"),
570 ];
571 for (input, expected) in cases {
572 check!(format_thousands(input) == expected);
573 }
574 }
575
576 #[test]
577 fn plural_is_empty_only_for_one() {
578 check!(plural(0) == "s");
579 check!(plural(1) == "");
580 check!(plural(2) == "s");
581 }
582
583 #[test]
584 fn text_rendering_contains_key_facts() {
585 let report = sample_report(RestoreMode::Perform);
586 let text = report.render(ReportFormat::Text).unwrap();
587
588 check!(text.contains("orders"));
589 check!(text.contains("orders-archive"));
590 check!(text.contains(&report.cluster_id.to_string()));
591 check!(text.contains("partition 0"));
592 check!(text.contains("partition 1"));
593 check!(text.contains("[0, 12489]"));
594 check!(text.contains("12,480 records kept"));
595 check!(text.contains("9 dropped"));
596 check!(text.contains("1 batch rewritten"));
597 check!(text.contains("1 batch emptied"));
598 check!(text.contains("checksum mismatch in batch at offset 900"));
599 check!(text.contains("orders-2"));
600 }
601
602 #[yare::parameterized(
605 knows_every_topic = { &[], "" },
606 misses_a_topic = {
607 &["payments"],
608 "WARNING: --metadata-snapshot /backup/metadata.checkpoint does not hold these \
609 restored topics under their archived topic ids, so their configuration was not \
610 recovered: payments. Set their configuration again with kafka-configs\n"
611 },
612 )]
613 fn text_rendering_exact_snapshot_for_one_partition(missing: &[&str], warning: &str) {
614 let report = RestoreReport {
615 dry_run: false,
616 log_dir: "/var/lib/krabka/restored".into(),
617 cluster_id: Uuid::nil(),
618 authentication: None,
619 diskless: None,
620 metadata: MetadataRestoreReport {
621 snapshot: Some("/backup/metadata.checkpoint".into()),
622 topic_configs: 1,
623 access_control_entries: 0,
624 client_quotas: 0,
625 scram_credentials: 0,
626 feature_levels: 0,
627 topics_without_configuration: missing
628 .iter()
629 .map(|topic| (*topic).to_owned())
630 .collect(),
631 },
632 partitions: vec![PartitionReport {
633 topic: "orders-archive".to_owned(),
634 partition: 0,
635 topic_id: Uuid::nil(),
636 segments: vec![segment().call()],
637 }],
638 skipped: vec![],
639 };
640
641 let expected = format!(
642 "krabka restore: wrote /var/lib/krabka/restored\n\
643 cluster id: 00000000-0000-0000-0000-000000000000\n\
644 {warning}metadata: 1 topic config, 0 ACLs, 0 client quotas, 0 SCRAM credentials, 0 feature levels\n\
645 \n\
646 orders-archive (topic id 00000000-0000-0000-0000-000000000000)\n\
647 \x20 partition 0: 1 segment, offsets [0, 402], 402 records kept, 0 dropped\n"
648 );
649
650 check!(report.render(ReportFormat::Text).unwrap() == expected);
651 }
652
653 #[test]
654 fn dry_run_is_visible_in_text() {
655 let dry = sample_report(RestoreMode::DryRun)
656 .render(ReportFormat::Text)
657 .unwrap();
658 let real = sample_report(RestoreMode::Perform)
659 .render(ReportFormat::Text)
660 .unwrap();
661
662 check!(dry.contains("dry run"));
663 check!(!real.contains("dry run"));
664 }
665
666 #[test]
667 fn empty_skipped_list_omits_section() {
668 let mut report = sample_report(RestoreMode::Perform);
669 report.skipped.clear();
670 let text = report.render(ReportFormat::Text).unwrap();
671
672 check!(!text.contains("skipped"));
673 }
674
675 #[test]
676 fn nonempty_skipped_list_names_every_segment_and_reason() {
677 let mut report = sample_report(RestoreMode::Perform);
678 report.skipped.push(SkippedSegment {
679 topic: "returns".to_owned(),
680 partition: 7,
681 segment_id: Uuid::from_u128(0xF00D),
682 reason: "truncated batch header".to_owned(),
683 });
684 let text = report.render(ReportFormat::Text).unwrap();
685
686 for skipped in &report.skipped {
687 check!(text.contains(&skipped.topic));
688 check!(text.contains(&skipped.segment_id.to_string()));
689 check!(text.contains(&skipped.reason));
690 }
691 }
692
693 #[test]
694 fn zero_segment_partition_does_not_panic() {
695 let report = RestoreReport {
696 dry_run: true,
697 log_dir: "/tmp/restore".into(),
698 cluster_id: Uuid::nil(),
699 authentication: None,
700 diskless: None,
701 metadata: MetadataRestoreReport {
702 snapshot: None,
703 topic_configs: 0,
704 access_control_entries: 0,
705 client_quotas: 0,
706 scram_credentials: 0,
707 feature_levels: 0,
708 topics_without_configuration: vec!["empty-topic".to_owned()],
709 },
710 partitions: vec![PartitionReport {
711 topic: "empty-topic".to_owned(),
712 partition: 0,
713 topic_id: Uuid::nil(),
714 segments: vec![],
715 }],
716 skipped: vec![],
717 };
718
719 let text = report.render(ReportFormat::Text).unwrap();
720
721 check!(text.contains("empty-topic"));
722 check!(text.contains("no segments restored"));
723 check!(text.contains("WARNING: no --metadata-snapshot"));
724 check!(text.contains("ACLs, client quotas, SCRAM credentials, and feature levels"));
725 }
726
727 #[yare::parameterized(
730 knows_every_topic = { &[], None },
731 misses_two_topics = {
732 &["orders", "payments"],
733 Some(
734 "WARNING: --metadata-snapshot cluster-metadata.checkpoint does not hold these \
735 restored topics under their archived topic ids, so their configuration was not \
736 recovered: orders, payments. Set their configuration again with kafka-configs"
737 )
738 },
739 )]
740 fn a_snapshot_warns_for_each_restored_topic_it_does_not_know(
741 missing: &[&str],
742 expected: Option<&str>,
743 ) {
744 let metadata = MetadataRestoreReport {
745 snapshot: Some(PathBuf::from("cluster-metadata.checkpoint")),
746 topic_configs: 0,
747 access_control_entries: 0,
748 client_quotas: 0,
749 scram_credentials: 0,
750 feature_levels: 0,
751 topics_without_configuration: missing.iter().map(|topic| (*topic).to_owned()).collect(),
752 };
753
754 check!(metadata.warning().as_deref() == expected);
755 }
756
757 #[test]
758 fn json_rendering_round_trips_field_values() {
759 let report = sample_report(RestoreMode::Perform);
760 let json = report.render(ReportFormat::Json).unwrap();
761 let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
762
763 check!(value["dry_run"] == serde_json::json!(false));
764 for (pointer, expected) in [
765 (
766 "/cluster_id",
767 serde_json::json!(report.cluster_id.to_string()),
768 ),
769 ("/partitions/0/topic", serde_json::json!("orders")),
770 ("/partitions/0/partition", serde_json::json!(0)),
771 (
772 "/partitions/0/segments/0/records_kept",
773 serde_json::json!(4995),
774 ),
775 (
776 "/partitions/0/segments/1/batches_emptied",
777 serde_json::json!(1),
778 ),
779 ] {
780 check!(value.pointer(pointer) == Some(&expected), "{pointer}");
781 }
782 check!(
783 value["skipped"][0]["reason"]
784 == serde_json::json!("checksum mismatch in batch at offset 900")
785 );
786 }
787}