Skip to main content

sst_inspect/
sst_inspect.rs

1//! SST file inspector binary for turbo-persistence databases.
2//!
3//! This tool inspects SST files to report entry type statistics per family,
4//! useful for verifying that inline value optimization is being used.
5//!
6//! Entry types:
7//! - 0: Small value (stored in value block)
8//! - 1: Blob reference
9//! - 2: Deleted/tombstone
10//! - 3: Medium value
11//! - 8-255: Inline value where (type - 8) = value byte count
12
13use 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
35/// Size of the key block header (1B type + 3B entry count).
36const KEY_BLOCK_HEADER_SIZE: usize = 4;
37
38/// Block size information
39#[derive(Default, Debug, Clone)]
40struct BlockSizeInfo {
41    /// Size as stored on disk (after compression, if any)
42    stored_size: u64,
43    /// Actual size (after decompression)
44    actual_size: u64,
45    /// Number of blocks that were compressed
46    compressed_count: u64,
47    /// Number of blocks stored uncompressed
48    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/// Statistics for a single SST file
75#[derive(Default, Debug, Clone)]
76struct SstStats {
77    /// Count of entries by type
78    entry_type_counts: BTreeMap<u8, u64>,
79    /// Total entries
80    total_entries: u64,
81
82    /// Index block sizes
83    index_blocks: BlockSizeInfo,
84    /// Key block sizes (all types combined)
85    key_blocks: BlockSizeInfo,
86    /// Variable-size key blocks (types 1/2)
87    variable_key_blocks: BlockSizeInfo,
88    /// Fixed-size key blocks (types 3/4)
89    fixed_key_blocks: BlockSizeInfo,
90    /// Value block sizes (small values)
91    value_blocks: BlockSizeInfo,
92
93    /// Block directory size (block_count * 4 bytes at end of file)
94    block_directory_size: u64,
95
96    /// Value sizes by type (inline values track actual bytes)
97    inline_value_bytes: u64,
98    small_value_refs: u64,  // Count of references to value blocks
99    medium_value_refs: u64, // Count of references to medium values
100    blob_refs: u64,         // Count of blob references
101    deleted_count: u64,     // Count of deleted entries
102
103    /// File size in bytes
104    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
128/// Information about an SST file from the meta file
129struct SstInfo {
130    sequence_number: u32,
131    block_count: u16,
132}
133
134/// Accumulates statistics for a single entry of the given type.
135fn 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
184/// Format a number with comma separators for readability
185fn 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
209/// Collect SST info from all active meta files in the database directory,
210/// mirroring the DB's own open logic: read CURRENT, filter by .del files,
211/// and apply SstFilter to skip superseded entries.
212fn collect_sst_info(db_path: &Path) -> Result<BTreeMap<u32, Vec<SstInfo>>> {
213    // Read the CURRENT sequence number — only files with seq <= current are valid.
214    let current: u32 = File::open(db_path.join("CURRENT"))?
215        .read_u32::<BE>()
216        .context("Failed to read CURRENT file")?;
217
218    // Read .del files to find sequences that were deleted but not yet cleaned up.
219    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    // Collect valid meta sequence numbers.
232    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    // Apply SstFilter (newest first) to drop entries superseded by a newer meta file.
261    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
280/// Information about a raw block read from disk.
281struct RawBlock {
282    data: Box<[u8]>,
283    compressed_size: u64,
284    actual_size: u64,
285    was_compressed: bool,
286}
287
288/// Reads, checksums, and decompresses a single block from the mmap.
289fn 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
351/// Parses an index block to extract all referenced key block indices.
352///
353/// Index block format: `[1B type][2B first_block][N * (8B hash + 2B block_index)]`.
354fn 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..]; // skip block type byte
357    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
369/// Parsed header of a key block.
370enum KeyBlockHeader {
371    Variable { entry_count: u32 },
372    Fixed { entry_count: u32, value_type: u8 },
373}
374
375/// Parses the header of a key block from the full decompressed block data.
376fn 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
395/// Iterates over entry type bytes in a key block.
396///
397/// For variable-size key blocks, reads byte 0 of each 4-byte offset table entry.
398/// For fixed-size key blocks, yields the single `value_type` repeated `entry_count` times.
399fn 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            // Variable block: offset table starts at byte 4 (after 1B type + 3B count),
415            // each entry is 4 bytes, first byte is the entry type.
416            let header_offset = KEY_BLOCK_HEADER_SIZE + i as usize * 4;
417            block[header_offset]
418        }
419    })
420}
421
422/// Analyze an SST file and return entry type statistics
423fn 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    // Read the index block (always the last block) first to learn which blocks are key blocks.
441    // Without this, we'd have to guess block types from their first byte, which is wrong for
442    // value blocks (they have no type header and their data can start with any byte).
443    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    // Now iterate through all blocks, using the key block set for classification.
459    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            // Value block — no type header, just raw data.
478            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    // Determine compression status
529    let all_uncompressed = info.compressed_count == 0;
530    let all_compressed = info.uncompressed_count == 0;
531
532    if all_uncompressed {
533        // All blocks uncompressed - just show size
534        println!(
535            "    {}: {} blocks (uncompressed), {}",
536            name,
537            format_number(total),
538            format_bytes(info.actual_size),
539        );
540    } else if all_compressed {
541        // All blocks compressed - show stored vs actual with savings
542        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        // Mixed - show breakdown
562        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        // Visual bar
596        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    // Per-file overhead
668    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    // Block statistics
686    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    // Entry type histogram
704    if !stats.entry_type_counts.is_empty() {
705        println!("  │");
706        print_entry_histogram(stats, "  │ ");
707    }
708
709    // Value storage summary
710    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    // Averages
729    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    // Per-file overhead
747    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        // Only show breakdown when both types are present
778        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    // Parse arguments
801    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    // Collect SST info grouped by family
849    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    // Analyze and report by family
859    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
881        print_family_summary(*family, sst_list.len(), &family_stats);
882
883        // Print per-SST details in verbose mode
884        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}