1use std::{
14 collections::{BTreeMap, HashSet},
15 path::{Path, PathBuf},
16};
17
18use anyhow::{Context, Result, bail};
19use byteorder::{BE, ReadBytesExt};
20use fs_err::{self as fs, File};
21use lzzzz::lz4::decompress;
22use memmap2::Mmap;
23use turbo_persistence::{
24 BLOCK_HEADER_SIZE, checksum_block,
25 meta_file::MetaFile,
26 mmap_helper::advise_mmap_for_persistence,
27 sst_filter::SstFilter,
28 static_sorted_file::{
29 BLOCK_TYPE_FIXED_KEY_NO_HASH, BLOCK_TYPE_FIXED_KEY_WITH_HASH, BLOCK_TYPE_KEY_NO_HASH,
30 BLOCK_TYPE_KEY_WITH_HASH, KEY_BLOCK_ENTRY_TYPE_BLOB, KEY_BLOCK_ENTRY_TYPE_DELETED,
31 KEY_BLOCK_ENTRY_TYPE_INLINE_MIN, KEY_BLOCK_ENTRY_TYPE_MEDIUM, KEY_BLOCK_ENTRY_TYPE_SMALL,
32 },
33};
34
35const KEY_BLOCK_HEADER_SIZE: usize = 4;
37
38#[derive(Default, Debug, Clone)]
40struct BlockSizeInfo {
41 stored_size: u64,
43 actual_size: u64,
45 compressed_count: u64,
47 uncompressed_count: u64,
49}
50
51impl BlockSizeInfo {
52 fn add(&mut self, stored: u64, actual: u64, was_compressed: bool) {
53 self.stored_size += stored;
54 self.actual_size += actual;
55 if was_compressed {
56 self.compressed_count += 1;
57 } else {
58 self.uncompressed_count += 1;
59 }
60 }
61
62 fn total_count(&self) -> u64 {
63 self.compressed_count + self.uncompressed_count
64 }
65
66 fn merge(&mut self, other: &BlockSizeInfo) {
67 self.stored_size += other.stored_size;
68 self.actual_size += other.actual_size;
69 self.compressed_count += other.compressed_count;
70 self.uncompressed_count += other.uncompressed_count;
71 }
72}
73
74#[derive(Default, Debug, Clone)]
76struct SstStats {
77 entry_type_counts: BTreeMap<u8, u64>,
79 total_entries: u64,
81
82 index_blocks: BlockSizeInfo,
84 key_blocks: BlockSizeInfo,
86 variable_key_blocks: BlockSizeInfo,
88 fixed_key_blocks: BlockSizeInfo,
90 value_blocks: BlockSizeInfo,
92
93 block_directory_size: u64,
95
96 inline_value_bytes: u64,
98 small_value_refs: u64, medium_value_refs: u64, blob_refs: u64, deleted_count: u64, file_size: u64,
105}
106
107impl SstStats {
108 fn merge(&mut self, other: &SstStats) {
109 for (ty, count) in &other.entry_type_counts {
110 *self.entry_type_counts.entry(*ty).or_insert(0) += count;
111 }
112 self.total_entries += other.total_entries;
113 self.index_blocks.merge(&other.index_blocks);
114 self.key_blocks.merge(&other.key_blocks);
115 self.variable_key_blocks.merge(&other.variable_key_blocks);
116 self.fixed_key_blocks.merge(&other.fixed_key_blocks);
117 self.value_blocks.merge(&other.value_blocks);
118 self.block_directory_size += other.block_directory_size;
119 self.inline_value_bytes += other.inline_value_bytes;
120 self.small_value_refs += other.small_value_refs;
121 self.medium_value_refs += other.medium_value_refs;
122 self.blob_refs += other.blob_refs;
123 self.deleted_count += other.deleted_count;
124 self.file_size += other.file_size;
125 }
126}
127
128struct SstInfo {
130 sequence_number: u32,
131 block_count: u16,
132}
133
134fn track_entry_type(stats: &mut SstStats, entry_type: u8) {
136 *stats.entry_type_counts.entry(entry_type).or_insert(0) += 1;
137 stats.total_entries += 1;
138
139 match entry_type {
140 KEY_BLOCK_ENTRY_TYPE_SMALL => {
141 stats.small_value_refs += 1;
142 }
143 KEY_BLOCK_ENTRY_TYPE_BLOB => {
144 stats.blob_refs += 1;
145 }
146 KEY_BLOCK_ENTRY_TYPE_DELETED => {
147 stats.deleted_count += 1;
148 }
149 KEY_BLOCK_ENTRY_TYPE_MEDIUM => {
150 stats.medium_value_refs += 1;
151 }
152 ty if ty >= KEY_BLOCK_ENTRY_TYPE_INLINE_MIN => {
153 let inline_size = (ty - KEY_BLOCK_ENTRY_TYPE_INLINE_MIN) as u64;
154 stats.inline_value_bytes += inline_size;
155 }
156 _ => {}
157 }
158}
159
160fn entry_type_description(ty: u8) -> String {
161 match ty {
162 KEY_BLOCK_ENTRY_TYPE_SMALL => "small value (in value block)".to_string(),
163 KEY_BLOCK_ENTRY_TYPE_BLOB => "blob reference".to_string(),
164 KEY_BLOCK_ENTRY_TYPE_DELETED => "deleted/tombstone".to_string(),
165 KEY_BLOCK_ENTRY_TYPE_MEDIUM => "medium value".to_string(),
166 ty if ty >= KEY_BLOCK_ENTRY_TYPE_INLINE_MIN => {
167 let inline_size = ty - KEY_BLOCK_ENTRY_TYPE_INLINE_MIN;
168 format!("inline {} bytes", inline_size)
169 }
170 _ => format!("unknown type {}", ty),
171 }
172}
173
174fn family_name(family: u32) -> &'static str {
175 match family {
176 0 => "Infra",
177 1 => "TaskMeta",
178 2 => "TaskData",
179 3 => "TaskCache",
180 _ => "Unknown",
181 }
182}
183
184fn format_number(n: u64) -> String {
186 let s = n.to_string();
187 let mut result = String::with_capacity(s.len() + s.len() / 3);
188 for (i, c) in s.chars().enumerate() {
189 if i > 0 && (s.len() - i).is_multiple_of(3) {
190 result.push(',');
191 }
192 result.push(c);
193 }
194 result
195}
196
197fn format_bytes(bytes: u64) -> String {
198 if bytes >= 1024 * 1024 * 1024 {
199 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
200 } else if bytes >= 1024 * 1024 {
201 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
202 } else if bytes >= 1024 {
203 format!("{:.2} KB", bytes as f64 / 1024.0)
204 } else {
205 format!("{} B", bytes)
206 }
207}
208
209fn collect_sst_info(db_path: &Path) -> Result<BTreeMap<u32, Vec<SstInfo>>> {
213 let current: u32 = File::open(db_path.join("CURRENT"))?
215 .read_u32::<BE>()
216 .context("Failed to read CURRENT file")?;
217
218 let mut deleted_seqs: HashSet<u32> = HashSet::new();
220 for entry in fs::read_dir(db_path)? {
221 let path = entry?.path();
222 if path.extension().and_then(|s| s.to_str()) == Some("del") {
223 let content = fs::read(&path)?;
224 let mut cursor: &[u8] = &content;
225 while !cursor.is_empty() {
226 deleted_seqs.insert(cursor.read_u32::<BE>()?);
227 }
228 }
229 }
230
231 let mut meta_seqs: Vec<u32> = fs::read_dir(db_path)?
233 .filter_map(|e| e.ok())
234 .filter_map(|e| {
235 let path = e.path();
236 if path.extension().and_then(|s| s.to_str()) != Some("meta") {
237 return None;
238 }
239 let seq: u32 = path.file_stem()?.to_str()?.parse().ok()?;
240 if seq > current || deleted_seqs.contains(&seq) {
241 return None;
242 }
243 Some(seq)
244 })
245 .collect();
246
247 if meta_seqs.is_empty() {
248 bail!("No active .meta files found in {}", db_path.display());
249 }
250
251 meta_seqs.sort_unstable();
252
253 let mut meta_files: Vec<MetaFile> = meta_seqs
254 .iter()
255 .map(|&seq| {
256 MetaFile::open(db_path, seq).with_context(|| format!("Failed to open {seq:08}.meta"))
257 })
258 .collect::<Result<_>>()?;
259
260 let mut sst_filter = SstFilter::new();
262 for meta in meta_files.iter_mut().rev() {
263 sst_filter.apply_filter(meta);
264 }
265
266 let mut family_sst_info: BTreeMap<u32, Vec<SstInfo>> = BTreeMap::new();
267 for meta in &meta_files {
268 let family = meta.family();
269 for entry in meta.entries() {
270 family_sst_info.entry(family).or_default().push(SstInfo {
271 sequence_number: entry.sequence_number(),
272 block_count: entry.block_count(),
273 });
274 }
275 }
276
277 Ok(family_sst_info)
278}
279
280struct RawBlock {
282 data: Box<[u8]>,
283 compressed_size: u64,
284 actual_size: u64,
285 was_compressed: bool,
286}
287
288fn read_block(
290 mmap: &Mmap,
291 block_offsets_start: usize,
292 block_index: u16,
293 sequence_number: u32,
294) -> Result<RawBlock> {
295 let offset = block_offsets_start + block_index as usize * size_of::<u32>();
296
297 let block_start = if block_index == 0 {
298 0
299 } else {
300 (&mmap[offset - size_of::<u32>()..offset]).read_u32::<BE>()? as usize
301 };
302 let block_end = (&mmap[offset..offset + size_of::<u32>()]).read_u32::<BE>()? as usize;
303
304 let uncompressed_length =
305 (&mmap[block_start..block_start + size_of::<u32>()]).read_u32::<BE>()?;
306 let expected_checksum = (&mmap
307 [block_start + size_of::<u32>()..block_start + BLOCK_HEADER_SIZE])
308 .read_u32::<BE>()?;
309 let compressed_data = &mmap[block_start + BLOCK_HEADER_SIZE..block_end];
310 let compressed_size = compressed_data.len() as u64;
311
312 let was_compressed = uncompressed_length > 0;
313 let actual_size = if was_compressed {
314 uncompressed_length as u64
315 } else {
316 compressed_size
317 };
318
319 let actual_checksum = checksum_block(compressed_data);
320 if actual_checksum != expected_checksum {
321 bail!(
322 "Cache corruption detected: checksum mismatch in block {} of {:08}.sst (expected \
323 {:08x}, got {:08x})",
324 block_index,
325 sequence_number,
326 expected_checksum,
327 actual_checksum
328 );
329 }
330
331 let data = if was_compressed {
332 let mut buffer = vec![0u8; uncompressed_length as usize];
333 let bytes_written = decompress(compressed_data, &mut buffer)?;
334 assert_eq!(
335 bytes_written, uncompressed_length as usize,
336 "Decompressed length does not match expected"
337 );
338 buffer.into_boxed_slice()
339 } else {
340 Box::from(compressed_data)
341 };
342
343 Ok(RawBlock {
344 data,
345 compressed_size,
346 actual_size,
347 was_compressed,
348 })
349}
350
351fn parse_key_block_indices(index_block: &[u8]) -> HashSet<u16> {
355 assert!(index_block.len() >= 3, "Index block too small");
356 let mut data = &index_block[1..]; let first_block = data.read_u16::<BE>().unwrap();
358 let mut indices = HashSet::new();
359 indices.insert(first_block);
360 const ENTRY_SIZE: usize = size_of::<u64>() + size_of::<u16>();
361 let entry_count = data.len() / ENTRY_SIZE;
362 for i in 0..entry_count {
363 let block_index = (&data[i * ENTRY_SIZE + 8..]).read_u16::<BE>().unwrap();
364 indices.insert(block_index);
365 }
366 indices
367}
368
369enum KeyBlockHeader {
371 Variable { entry_count: u32 },
372 Fixed { entry_count: u32, value_type: u8 },
373}
374
375fn parse_key_block_header(block: &[u8]) -> Result<KeyBlockHeader> {
377 assert!(block.len() >= 4, "Key block too small");
378 let block_type = block[0];
379 let entry_count = ((block[1] as u32) << 16) | ((block[2] as u32) << 8) | (block[3] as u32);
380 match block_type {
381 BLOCK_TYPE_KEY_WITH_HASH | BLOCK_TYPE_KEY_NO_HASH => {
382 Ok(KeyBlockHeader::Variable { entry_count })
383 }
384 BLOCK_TYPE_FIXED_KEY_WITH_HASH | BLOCK_TYPE_FIXED_KEY_NO_HASH => {
385 assert!(block.len() >= 6, "Fixed key block header too small");
386 Ok(KeyBlockHeader::Fixed {
387 entry_count,
388 value_type: block[5],
389 })
390 }
391 _ => bail!("Invalid key block type: {block_type}"),
392 }
393}
394
395fn iter_key_block_entry_types(
400 header: KeyBlockHeader,
401 block: &[u8],
402) -> impl Iterator<Item = u8> + '_ {
403 let (entry_count, fixed_type) = match header {
404 KeyBlockHeader::Variable { entry_count } => (entry_count, None),
405 KeyBlockHeader::Fixed {
406 entry_count,
407 value_type,
408 } => (entry_count, Some(value_type)),
409 };
410 (0..entry_count).map(move |i| {
411 if let Some(vt) = fixed_type {
412 vt
413 } else {
414 let header_offset = KEY_BLOCK_HEADER_SIZE + i as usize * 4;
417 block[header_offset]
418 }
419 })
420}
421
422fn analyze_sst_file(db_path: &Path, info: &SstInfo) -> Result<SstStats> {
424 let filename = format!("{:08}.sst", info.sequence_number);
425 let path = db_path.join(&filename);
426
427 let file = File::open(&path)?;
428 let file_size = file.metadata()?.len();
429 let mmap = unsafe { Mmap::map(file.file())? };
430 advise_mmap_for_persistence(&mmap)?;
431
432 let mut stats = SstStats {
433 block_directory_size: info.block_count as u64 * size_of::<u32>() as u64,
434 file_size,
435 ..Default::default()
436 };
437
438 let block_offsets_start = mmap.len() - (info.block_count as usize * size_of::<u32>());
439
440 let index_block_index = info.block_count - 1;
444 let index_raw = read_block(
445 &mmap,
446 block_offsets_start,
447 index_block_index,
448 info.sequence_number,
449 )?;
450 let key_block_indices = parse_key_block_indices(&index_raw.data);
451
452 stats.index_blocks.add(
453 index_raw.compressed_size,
454 index_raw.actual_size,
455 index_raw.was_compressed,
456 );
457
458 for block_index in 0..index_block_index {
460 let raw = match read_block(
461 &mmap,
462 block_offsets_start,
463 block_index,
464 info.sequence_number,
465 ) {
466 Ok(raw) => raw,
467 Err(e) => {
468 eprintln!(
469 "Warning: Failed to read block {} in {:08}.sst: {}",
470 block_index, info.sequence_number, e
471 );
472 continue;
473 }
474 };
475
476 if !key_block_indices.contains(&block_index) {
477 stats
479 .value_blocks
480 .add(raw.compressed_size, raw.actual_size, raw.was_compressed);
481 continue;
482 }
483
484 let block: &[u8] = &raw.data;
485
486 stats
487 .key_blocks
488 .add(raw.compressed_size, raw.actual_size, raw.was_compressed);
489
490 let key_block_header = parse_key_block_header(block).with_context(|| {
491 format!(
492 "Warning: key block {} in {:08}.sst has unexpected block type {}",
493 block_index, info.sequence_number, block[0]
494 )
495 })?;
496 match key_block_header {
497 KeyBlockHeader::Variable { .. } => {
498 stats.variable_key_blocks.add(
499 raw.compressed_size,
500 raw.actual_size,
501 raw.was_compressed,
502 );
503 }
504 KeyBlockHeader::Fixed { .. } => {
505 stats.fixed_key_blocks.add(
506 raw.compressed_size,
507 raw.actual_size,
508 raw.was_compressed,
509 );
510 }
511 };
512
513 for entry_type in iter_key_block_entry_types(key_block_header, block) {
514 track_entry_type(&mut stats, entry_type);
515 }
516 }
517
518 Ok(stats)
519}
520
521fn print_block_stats(name: &str, info: &BlockSizeInfo) {
522 let total = info.total_count();
523 if total == 0 {
524 println!(" {}: none", name);
525 return;
526 }
527
528 let all_uncompressed = info.compressed_count == 0;
530 let all_compressed = info.uncompressed_count == 0;
531
532 if all_uncompressed {
533 println!(
535 " {}: {} blocks (uncompressed), {}",
536 name,
537 format_number(total),
538 format_bytes(info.actual_size),
539 );
540 } else if all_compressed {
541 let savings_pct = if info.actual_size > 0 {
543 ((info.actual_size as f64 - info.stored_size as f64) / info.actual_size as f64) * 100.0
544 } else {
545 0.0
546 };
547 let savings_str = if savings_pct < 0.0 {
548 format!("{:.0}% overhead", -savings_pct)
549 } else {
550 format!("{:.0}% savings", savings_pct)
551 };
552 println!(
553 " {}: {} blocks, stored: {}, actual: {} ({})",
554 name,
555 format_number(total),
556 format_bytes(info.stored_size),
557 format_bytes(info.actual_size),
558 savings_str,
559 );
560 } else {
561 let savings_pct = if info.actual_size > 0 {
563 ((info.actual_size as f64 - info.stored_size as f64) / info.actual_size as f64) * 100.0
564 } else {
565 0.0
566 };
567 let savings_str = if savings_pct < 0.0 {
568 format!("{:.0}% overhead", -savings_pct)
569 } else {
570 format!("{:.0}% savings", savings_pct)
571 };
572 println!(
573 " {}: {} blocks ({} compressed, {} uncompressed)",
574 name,
575 format_number(total),
576 format_number(info.compressed_count),
577 format_number(info.uncompressed_count),
578 );
579 println!(
580 " stored: {}, actual: {} ({})",
581 format_bytes(info.stored_size),
582 format_bytes(info.actual_size),
583 savings_str,
584 );
585 }
586}
587
588fn print_entry_histogram(stats: &SstStats, prefix: &str) {
589 if stats.entry_type_counts.is_empty() {
590 return;
591 }
592 println!("{}Entry Type Histogram:", prefix);
593 for (ty, count) in &stats.entry_type_counts {
594 let pct = (*count as f64 / stats.total_entries as f64) * 100.0;
595 let bar_len = (pct / 2.0) as usize;
597 let bar: String = "█".repeat(bar_len.min(40));
598 println!(
599 "{} type {:3}: {:>12} ({:5.1}%) │{}│ {}",
600 prefix,
601 ty,
602 format_number(*count),
603 pct,
604 bar,
605 entry_type_description(*ty),
606 );
607 }
608}
609
610fn print_value_storage(stats: &SstStats, prefix: &str) {
611 println!("{}Value Storage:", prefix);
612 if stats.inline_value_bytes > 0 {
613 let inline_count: u64 = stats
614 .entry_type_counts
615 .iter()
616 .filter(|(ty, _)| **ty >= KEY_BLOCK_ENTRY_TYPE_INLINE_MIN)
617 .map(|(_, count)| count)
618 .sum();
619 println!(
620 "{} Inline: {} entries, {} total",
621 prefix,
622 format_number(inline_count),
623 format_bytes(stats.inline_value_bytes)
624 );
625 }
626 if stats.small_value_refs > 0 {
627 println!(
628 "{} Small (value block refs): {} entries",
629 prefix,
630 format_number(stats.small_value_refs)
631 );
632 }
633 if stats.medium_value_refs > 0 {
634 println!(
635 "{} Medium (dedicated blocks): {} entries",
636 prefix,
637 format_number(stats.medium_value_refs)
638 );
639 }
640 if stats.blob_refs > 0 {
641 println!(
642 "{} Blob (external files): {} entries",
643 prefix,
644 format_number(stats.blob_refs)
645 );
646 }
647 if stats.deleted_count > 0 {
648 println!(
649 "{} Deleted: {} entries",
650 prefix,
651 format_number(stats.deleted_count)
652 );
653 }
654}
655
656fn print_sst_details(seq_num: u32, stats: &SstStats) {
657 println!(
658 "\n ┌─ SST {:08}.sst ─────────────────────────────────────────────────────",
659 seq_num
660 );
661 println!(
662 " │ Entries: {}, File size: {}",
663 format_number(stats.total_entries),
664 format_bytes(stats.file_size)
665 );
666
667 let overhead = stats.block_directory_size;
669 let overhead_pct = if stats.file_size > 0 {
670 (overhead as f64 / stats.file_size as f64) * 100.0
671 } else {
672 0.0
673 };
674 println!(" │");
675 println!(
676 " │ Per-file Overhead: {} ({:.1}% of file)",
677 format_bytes(overhead),
678 overhead_pct
679 );
680 println!(
681 " │ Block directory: {}",
682 format_bytes(stats.block_directory_size)
683 );
684
685 println!(" │");
687 println!(" │ Block Statistics:");
688 print!(" │ ");
689 print_block_stats("Index blocks", &stats.index_blocks);
690 print!(" │ ");
691 print_block_stats("Key blocks", &stats.key_blocks);
692 if stats.variable_key_blocks.total_count() > 0 && stats.fixed_key_blocks.total_count() > 0 {
693 print!(" │ ");
694 print_block_stats("Variable", &stats.variable_key_blocks);
695 print!(" │ ");
696 print_block_stats("Fixed", &stats.fixed_key_blocks);
697 } else if stats.fixed_key_blocks.total_count() > 0 {
698 println!(" │ (all fixed-size)");
699 }
700 print!(" │ ");
701 print_block_stats("Value blocks", &stats.value_blocks);
702
703 if !stats.entry_type_counts.is_empty() {
705 println!(" │");
706 print_entry_histogram(stats, " │ ");
707 }
708
709 println!(" │");
711 print_value_storage(stats, " │ ");
712
713 println!(" └───────────────────────────────────────────────────────────────────────────");
714}
715
716fn print_family_summary(family: u32, sst_count: usize, stats: &SstStats) {
717 println!("═══════════════════════════════════════════════════════════════════════════════");
718 println!("Family {} ({}):", family, family_name(family));
719 println!("═══════════════════════════════════════════════════════════════════════════════");
720
721 println!(
722 " SST files: {}, Total entries: {}",
723 format_number(sst_count as u64),
724 format_number(stats.total_entries)
725 );
726 println!(" Total file size: {}", format_bytes(stats.file_size));
727
728 if sst_count > 0 {
730 let avg_file_size = stats.file_size / sst_count as u64;
731 let avg_keys_per_file = stats.total_entries / sst_count as u64;
732 let total_key_blocks = stats.key_blocks.total_count();
733 let avg_keys_per_block = if total_key_blocks > 0 {
734 stats.total_entries as f64 / total_key_blocks as f64
735 } else {
736 0.0
737 };
738
739 println!();
740 println!(" Averages:");
741 println!(" File size: {}", format_bytes(avg_file_size));
742 println!(" Keys per file: {}", format_number(avg_keys_per_file));
743 println!(" Keys per key block: {:.1}", avg_keys_per_block);
744 }
745
746 let total_overhead = stats.block_directory_size;
748 let overhead_pct = if stats.file_size > 0 {
749 (total_overhead as f64 / stats.file_size as f64) * 100.0
750 } else {
751 0.0
752 };
753 println!();
754 println!(
755 " Per-file Overhead (total): {} ({:.1}% of total file size)",
756 format_bytes(total_overhead),
757 overhead_pct
758 );
759 println!(
760 " Block directories: {}",
761 format_bytes(stats.block_directory_size)
762 );
763 if sst_count > 0 {
764 println!(
765 " Average per file: {}",
766 format_bytes(stats.block_directory_size / sst_count as u64)
767 );
768 }
769
770 println!();
771 println!(" Block Statistics:");
772 print!(" ");
773 print_block_stats("Index blocks", &stats.index_blocks);
774 print!(" ");
775 print_block_stats("Key blocks", &stats.key_blocks);
776 if stats.variable_key_blocks.total_count() > 0 && stats.fixed_key_blocks.total_count() > 0 {
777 print!(" ");
779 print_block_stats("Variable", &stats.variable_key_blocks);
780 print!(" ");
781 print_block_stats("Fixed", &stats.fixed_key_blocks);
782 } else if stats.fixed_key_blocks.total_count() > 0 {
783 println!(" (all fixed-size)");
784 }
785 print!(" ");
786 print_block_stats("Value blocks", &stats.value_blocks);
787
788 println!();
789 print_entry_histogram(stats, " ");
790
791 println!();
792 print_value_storage(stats, " ");
793
794 println!();
795}
796
797fn main() -> Result<()> {
798 let args: Vec<String> = std::env::args().collect();
799
800 let mut db_path: Option<PathBuf> = None;
802 let mut verbose = false;
803
804 let mut i = 1;
805 while i < args.len() {
806 match args[i].as_str() {
807 "--verbose" | "-v" => verbose = true,
808 arg if !arg.starts_with('-') => {
809 if db_path.is_none() {
810 db_path = Some(PathBuf::from(arg));
811 }
812 }
813 _ => {
814 eprintln!("Unknown option: {}", args[i]);
815 std::process::exit(1);
816 }
817 }
818 i += 1;
819 }
820
821 let db_path = match db_path {
822 Some(p) => p,
823 None => {
824 eprintln!("Usage: {} [OPTIONS] <db_directory>", args[0]);
825 eprintln!();
826 eprintln!("Inspects turbo-persistence SST files to report entry type statistics.");
827 eprintln!();
828 eprintln!("Options:");
829 eprintln!(" -v, --verbose Show per-SST file details (default: family totals only)");
830 eprintln!();
831 eprintln!("Entry types:");
832 eprintln!(" 0: Small value (stored in separate value block)");
833 eprintln!(" 1: Blob reference");
834 eprintln!(" 2: Deleted/tombstone");
835 eprintln!(" 3: Medium value");
836 eprintln!(" 8+: Inline value (size = type - 8)");
837 eprintln!();
838 eprintln!("For TaskCache (family 3), values are 4-byte TaskIds.");
839 eprintln!("Expected entry type is 12 (8 + 4) for inline optimization.");
840 std::process::exit(1);
841 }
842 };
843
844 if !db_path.is_dir() {
845 bail!("Not a directory: {}", db_path.display());
846 }
847
848 let family_sst_info = collect_sst_info(&db_path)?;
850
851 let total_sst_count: usize = family_sst_info.values().map(|v| v.len()).sum();
852 println!(
853 "Analyzing {} SST files in {}\n",
854 format_number(total_sst_count as u64),
855 db_path.display()
856 );
857
858 for (family, sst_list) in &family_sst_info {
860 let mut family_stats = SstStats::default();
861 let mut sst_stats_list: Vec<(u32, SstStats)> = Vec::new();
862
863 for info in sst_list {
864 match analyze_sst_file(&db_path, info) {
865 Ok(stats) => {
866 family_stats.merge(&stats);
867 if verbose {
868 sst_stats_list.push((info.sequence_number, stats));
869 }
870 }
871 Err(e) => {
872 eprintln!(
873 "Warning: Failed to analyze {:08}.sst: {}",
874 info.sequence_number, e
875 );
876 }
877 }
878 }
879
880 print_family_summary(*family, sst_list.len(), &family_stats);
882
883 if verbose && !sst_stats_list.is_empty() {
885 println!(" Per-SST Details:");
886 for (seq_num, stats) in &sst_stats_list {
887 print_sst_details(*seq_num, stats);
888 }
889 println!();
890 }
891 }
892
893 Ok(())
894}