use super::vint::parse_vint;
use nom::{
bytes::complete::take,
number::complete::{be_i64, be_u32, be_u64, be_u8},
IResult,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatisticsHeader {
pub version: u32,
pub statistics_kind: u32,
pub data_length: u32,
pub metadata1: u32,
pub metadata2: u32,
pub metadata3: u32,
pub checksum: u32,
pub table_id: Option<[u8; 16]>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SSTableStatistics {
pub header: StatisticsHeader,
pub row_stats: RowStatistics,
pub timestamp_stats: TimestampStatistics,
pub column_stats: Vec<ColumnStatistics>,
pub table_stats: Option<TableStatistics>,
pub partition_stats: Option<PartitionStatistics>,
pub compression_stats: Option<CompressionStatistics>,
pub metadata: HashMap<String, String>,
#[serde(default)]
pub serialization_header_columns: Vec<super::header::ColumnInfo>,
#[serde(default)]
pub serialization_header_partition_keys: Vec<super::header::ColumnInfo>,
#[serde(default)]
pub serialization_header_clustering_keys: Vec<super::header::ColumnInfo>,
#[serde(default)]
pub tombstone_drop_times: Vec<(i64, u64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RowStatistics {
pub total_rows: u64,
pub live_rows: u64,
pub tombstone_count: u64,
pub partition_count: u64,
pub avg_rows_per_partition: f64,
pub row_size_histogram: Vec<RowSizeBucket>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampStatistics {
pub min_timestamp: i64,
pub max_timestamp: Option<i64>,
pub min_deletion_time: i64,
pub max_deletion_time: i64,
pub min_ttl: Option<i64>,
pub max_ttl: Option<i64>,
pub rows_with_ttl: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnStatistics {
pub name: String,
pub column_type: String,
pub value_count: u64,
pub null_count: u64,
pub min_value: Option<Vec<u8>>,
pub max_value: Option<Vec<u8>>,
pub avg_size: f64,
pub cardinality: u64,
pub value_histogram: Vec<ValueFrequency>,
pub has_index: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableStatistics {
pub disk_size: u64,
pub uncompressed_size: u64,
pub compressed_size: u64,
pub compression_ratio: f64,
pub block_count: u64,
pub avg_block_size: f64,
pub index_size: u64,
pub bloom_filter_size: u64,
pub level_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartitionStatistics {
pub avg_partition_size: f64,
pub min_partition_size: u64,
pub max_partition_size: u64,
pub size_histogram: Vec<PartitionSizeBucket>,
pub large_partition_percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressionStatistics {
pub algorithm: String,
pub original_size: u64,
pub compressed_size: u64,
pub ratio: f64,
pub compression_speed: f64,
pub decompression_speed: f64,
pub compressed_blocks: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RowSizeBucket {
pub size_start: u64,
pub size_end: u64,
pub count: u64,
pub percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValueFrequency {
pub value: Vec<u8>,
pub frequency: u64,
pub percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartitionSizeBucket {
pub size_start: u64,
pub size_end: u64,
pub count: u64,
pub cumulative_percentage: f64,
}
pub fn parse_statistics_header(input: &[u8]) -> IResult<&[u8], StatisticsHeader> {
let (remaining, version) = be_u32(input)?;
match version {
4 => parse_nb_format_header(remaining, version),
1..=3 => parse_legacy_format_header(remaining, version),
_ => Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Verify,
))),
}
}
fn parse_nb_format_header(input: &[u8], version: u32) -> IResult<&[u8], StatisticsHeader> {
let (input, statistics_kind) = be_u32(input)?;
let (input, _reserved) = be_u32(input)?;
let (input, data_length) = be_u32(input)?;
let (input, metadata1) = be_u32(input)?;
let (input, metadata2) = be_u32(input)?;
let (input, metadata3) = be_u32(input)?;
let (input, checksum) = be_u32(input)?;
Ok((
input,
StatisticsHeader {
version,
statistics_kind,
data_length,
metadata1,
metadata2,
metadata3,
checksum,
table_id: None, },
))
}
fn parse_legacy_format_header(input: &[u8], version: u32) -> IResult<&[u8], StatisticsHeader> {
let (input, table_id_raw) = take(16u8)(input)?;
let mut table_id_array = [0u8; 16];
table_id_array.copy_from_slice(table_id_raw);
let (input, section_count) = be_u32(input)?;
let (input, file_size) = be_u64(input)?;
let (input, checksum) = be_u32(input)?;
Ok((
input,
StatisticsHeader {
version,
statistics_kind: 0, data_length: section_count,
metadata1: (file_size >> 32) as u32,
metadata2: file_size as u32,
metadata3: 0,
checksum,
table_id: Some(table_id_array),
},
))
}
pub fn parse_timestamp_statistics(input: &[u8]) -> IResult<&[u8], TimestampStatistics> {
let (input, min_timestamp) = be_i64(input)?;
let (input, max_timestamp) = be_i64(input)?;
let (input, min_deletion_time) = be_i64(input)?;
let (input, max_deletion_time) = be_i64(input)?;
let (input, has_ttl) = be_u8(input)?;
let (input, min_ttl, max_ttl, rows_with_ttl) = if has_ttl != 0 {
let (input, min_ttl) = be_i64(input)?;
let (input, max_ttl) = be_i64(input)?;
let (input, rows_with_ttl) = parse_vint_as_u64(input)?;
(input, Some(min_ttl), Some(max_ttl), rows_with_ttl)
} else {
(input, None, None, 0)
};
Ok((
input,
TimestampStatistics {
min_timestamp,
max_timestamp: crate::parser::repair_metadata::decode_max_timestamp(max_timestamp),
min_deletion_time,
max_deletion_time,
min_ttl,
max_ttl,
rows_with_ttl: Some(rows_with_ttl),
},
))
}
fn parse_vint_as_u64(input: &[u8]) -> IResult<&[u8], u64> {
let (input, value) = parse_vint(input)?;
Ok((input, value as u64))
}
pub(crate) fn avg_rows_available(stats: &SSTableStatistics) -> bool {
stats.row_stats.total_rows > 0 && stats.row_stats.partition_count > 0
}
pub struct StatisticsAnalyzer;
impl StatisticsAnalyzer {
pub fn analyze(stats: &SSTableStatistics) -> StatisticsSummary {
let data_efficiency = Self::calculate_data_efficiency(stats);
let query_performance_hints = Self::generate_query_hints(stats);
let storage_recommendations = Self::generate_storage_recommendations(stats);
let health_score = Self::calculate_health_score(stats);
StatisticsSummary {
total_rows: stats.row_stats.total_rows,
live_data_percentage: Self::live_data_percentage(stats),
compression_efficiency: stats.compression_stats.as_ref().map(|c| c.ratio * 100.0),
timestamp_range_days: Self::calculate_timestamp_range_days(stats),
largest_partition_mb: stats
.partition_stats
.as_ref()
.map(|p| p.max_partition_size as f64 / 1_048_576.0),
data_efficiency,
query_performance_hints,
storage_recommendations,
health_score,
}
}
fn live_data_percentage(stats: &SSTableStatistics) -> Option<f64> {
if stats.row_stats.live_rows == 0 || stats.row_stats.total_rows == 0 {
return None;
}
Some((stats.row_stats.live_rows as f64 / stats.row_stats.total_rows as f64) * 100.0)
}
fn calculate_data_efficiency(stats: &SSTableStatistics) -> Option<f64> {
if stats.row_stats.live_rows == 0 || stats.row_stats.total_rows == 0 {
return None;
}
let (Some(compression), Some(partition)) = (
stats.compression_stats.as_ref(),
stats.partition_stats.as_ref(),
) else {
return None;
};
let live_ratio = stats.row_stats.live_rows as f64 / stats.row_stats.total_rows as f64;
let compression_ratio = compression.ratio;
let partition_efficiency = 1.0 - (partition.large_partition_percentage / 100.0);
Some((live_ratio + compression_ratio + partition_efficiency) / 3.0 * 100.0)
}
fn generate_query_hints(stats: &SSTableStatistics) -> Vec<String> {
let mut hints = Vec::new();
if let Some(partition) = stats.partition_stats.as_ref() {
if partition.large_partition_percentage > 10.0 {
hints.push("Consider reviewing partition key design - high percentage of large partitions detected".to_string());
}
}
if stats.row_stats.live_rows > 0
&& stats.row_stats.tombstone_count > stats.row_stats.live_rows / 4
{
hints.push("High tombstone ratio - consider running compaction".to_string());
}
if let Some(table) = stats.table_stats.as_ref() {
if table.compression_ratio < 0.5 {
hints.push("Low compression ratio - data may not be well-suited for current compression algorithm".to_string());
}
}
hints
}
fn generate_storage_recommendations(stats: &SSTableStatistics) -> Vec<String> {
let mut recommendations = Vec::new();
if let Some(table) = stats.table_stats.as_ref() {
if table.disk_size > 1_073_741_824 {
recommendations
.push("Large SSTable detected - consider more frequent compaction".to_string());
}
}
if avg_rows_available(stats) && stats.row_stats.avg_rows_per_partition < 10.0 {
recommendations.push(
"Low average rows per partition - partition key may be too granular".to_string(),
);
}
recommendations
}
fn calculate_health_score(stats: &SSTableStatistics) -> f64 {
let mut score = 100.0;
if stats.row_stats.total_rows > 0 {
let tombstone_ratio =
stats.row_stats.tombstone_count as f64 / stats.row_stats.total_rows as f64;
score -= tombstone_ratio * 30.0;
}
if let Some(compression) = stats.compression_stats.as_ref() {
if compression.ratio < 0.5 {
score -= 20.0;
}
}
if let Some(partition) = stats.partition_stats.as_ref() {
score -= partition.large_partition_percentage;
}
score.max(0.0)
}
fn calculate_timestamp_range_days(stats: &SSTableStatistics) -> f64 {
let min = stats.timestamp_stats.min_timestamp;
let max = match stats.timestamp_stats.max_timestamp {
Some(max) if max >= min => max,
_ => return 0.0,
};
let range_micros = max.saturating_sub(min);
range_micros as f64 / (1_000_000.0 * 60.0 * 60.0 * 24.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatisticsSummary {
pub total_rows: u64,
pub live_data_percentage: Option<f64>,
pub compression_efficiency: Option<f64>,
pub timestamp_range_days: f64,
pub largest_partition_mb: Option<f64>,
pub data_efficiency: Option<f64>,
pub query_performance_hints: Vec<String>,
pub storage_recommendations: Vec<String>,
pub health_score: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_statistics_header_parsing() {
let test_data = vec![
0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x12, 0x34, 0x56, 0x78, ];
let result = parse_statistics_header(&test_data);
assert!(result.is_ok());
let (_, header) = result.unwrap();
assert_eq!(header.version, 1);
assert_eq!(header.checksum, 0x12345678);
}
#[test]
fn test_nb_format_authoritative_detection() {
let nb_data = vec![
0x00, 0x00, 0x00, 0x04, 0x26, 0x29, 0x1b, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x14, 0xd4, ];
let result = parse_statistics_header(&nb_data);
assert!(result.is_ok());
let (_, header) = result.unwrap();
assert_eq!(header.version, 4);
assert_eq!(header.statistics_kind, 0x26291b05);
assert_eq!(header.data_length, 44);
assert!(header.table_id.is_none()); }
#[test]
fn test_legacy_format_authoritative_detection() {
let legacy_data = vec![
0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0xAB, 0xCD, 0xEF, 0x12, ];
let result = parse_statistics_header(&legacy_data);
assert!(result.is_ok());
let (_, header) = result.unwrap();
assert_eq!(header.version, 2);
assert_eq!(header.statistics_kind, 0); assert!(header.table_id.is_some()); }
#[test]
fn test_unsupported_version_rejection() {
let invalid_v0 = vec![
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ];
assert!(parse_statistics_header(&invalid_v0).is_err());
let invalid_v5 = vec![
0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, ];
assert!(parse_statistics_header(&invalid_v5).is_err());
let invalid_v255 = vec![
0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, ];
assert!(parse_statistics_header(&invalid_v255).is_err());
}
#[test]
fn test_no_heuristics_version_4_with_short_input() {
let short_nb_data = vec![
0x00, 0x00, 0x00, 0x04, 0x26, 0x29, 0x1b, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x2c, ];
let result = parse_statistics_header(&short_nb_data);
assert!(result.is_err());
}
#[test]
fn test_statistics_analyzer() {
let stats = create_test_statistics();
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(summary.total_rows > 0);
assert!(summary.health_score >= 0.0 && summary.health_score <= 100.0);
let live_pct = summary
.live_data_percentage
.expect("fixture has authoritative live_rows > 0");
assert!((0.0..=100.0).contains(&live_pct));
}
#[test]
fn test_live_data_percentage_unavailable_when_live_rows_sentinel() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 1000; stats.row_stats.live_rows = 0;
let summary = StatisticsAnalyzer::analyze(&stats);
assert_eq!(
summary.live_data_percentage, None,
"live_rows==0 sentinel with total_rows>0 must report None, not 0.00%"
);
assert_eq!(
summary.data_efficiency, None,
"data_efficiency must be None when live_rows is the unavailable sentinel"
);
assert_eq!(summary.total_rows, 1000);
assert!(
summary.health_score.is_finite(),
"health_score must stay finite when live_rows is the sentinel"
);
}
#[test]
fn test_query_hint_suppressed_when_live_rows_sentinel() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 1000;
stats.row_stats.live_rows = 0; stats.row_stats.tombstone_count = 500;
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
!summary
.query_performance_hints
.iter()
.any(|h| h.contains("tombstone")),
"tombstone hint must be suppressed when live_rows is the unavailable sentinel"
);
}
#[test]
fn test_derived_metrics_available_when_live_rows_present() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 1000;
stats.row_stats.live_rows = 100; stats.row_stats.tombstone_count = 500;
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
summary.live_data_percentage.is_some(),
"live_data_percentage must be Some when live_rows > 0"
);
let eff = summary
.data_efficiency
.expect("data_efficiency must be Some when live_rows > 0");
assert!(eff.is_finite());
assert!(
summary
.query_performance_hints
.iter()
.any(|h| h.contains("tombstone")),
"tombstone hint must fire when tombstones exceed live_rows/4"
);
}
#[test]
fn test_health_score_finite_when_total_rows_zero() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 0;
stats.row_stats.live_rows = 0;
stats.row_stats.tombstone_count = 0;
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
summary.health_score.is_finite(),
"health_score must be finite (not NaN) when total_rows == 0"
);
assert!((0.0..=100.0).contains(&summary.health_score));
}
#[test]
fn test_granularity_recommendation_suppressed_when_avg_rows_sentinel() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 0;
stats.row_stats.avg_rows_per_partition = 0.0;
if let Some(t) = stats.table_stats.as_mut() {
t.disk_size = 1024;
}
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
!summary
.storage_recommendations
.iter()
.any(|r| r.contains("too granular")),
"granularity recommendation must be suppressed when avg_rows_per_partition \
is the unavailable sentinel (total_rows == 0)"
);
}
#[test]
fn test_granularity_recommendation_fires_when_avg_rows_real_and_low() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 8; stats.row_stats.partition_count = 4;
stats.row_stats.avg_rows_per_partition = 2.0; if let Some(t) = stats.table_stats.as_mut() {
t.disk_size = 1024;
}
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
summary
.storage_recommendations
.iter()
.any(|r| r.contains("too granular")),
"granularity recommendation must fire for a REAL low avg_rows_per_partition"
);
}
#[test]
fn test_granularity_recommendation_absent_when_avg_rows_real_and_high() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 1000; if let Some(t) = stats.table_stats.as_mut() {
t.disk_size = 1024;
}
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
!summary
.storage_recommendations
.iter()
.any(|r| r.contains("too granular")),
"granularity recommendation must NOT fire for a REAL high avg_rows_per_partition"
);
}
#[test]
fn test_granularity_recommendation_suppressed_when_partition_count_zero() {
let mut stats = create_test_statistics();
stats.row_stats.total_rows = 8; stats.row_stats.partition_count = 0; stats.row_stats.avg_rows_per_partition = 0.0;
if let Some(t) = stats.table_stats.as_mut() {
t.disk_size = 1024;
}
assert!(
!avg_rows_available(&stats),
"avg_rows must be unavailable when partition_count == 0 despite total_rows > 0"
);
let summary = StatisticsAnalyzer::analyze(&stats);
assert!(
!summary
.storage_recommendations
.iter()
.any(|r| r.contains("too granular")),
"granularity recommendation must be suppressed when partition_count == 0 \
(avg_rows_per_partition is the unavailable sentinel)"
);
}
#[test]
fn test_parse_timestamp_statistics_no_ttl() {
let mut data = Vec::new();
data.extend_from_slice(&1000000i64.to_be_bytes()); data.extend_from_slice(&2000000i64.to_be_bytes()); data.extend_from_slice(&0i64.to_be_bytes()); data.extend_from_slice(&0i64.to_be_bytes()); data.push(0);
let result = parse_timestamp_statistics(&data);
assert!(result.is_ok());
let (remaining, ts_stats) = result.unwrap();
assert!(remaining.is_empty());
assert_eq!(ts_stats.min_timestamp, 1000000);
assert_eq!(ts_stats.max_timestamp, Some(2000000));
assert_eq!(ts_stats.min_deletion_time, 0);
assert_eq!(ts_stats.max_deletion_time, 0);
assert!(ts_stats.min_ttl.is_none());
assert!(ts_stats.max_ttl.is_none());
assert_eq!(ts_stats.rows_with_ttl, Some(0));
}
#[test]
fn test_parse_timestamp_statistics_with_ttl() {
use super::super::vint::encode_vint;
let mut data = Vec::new();
data.extend_from_slice(&1000000i64.to_be_bytes());
data.extend_from_slice(&2000000i64.to_be_bytes());
data.extend_from_slice(&0i64.to_be_bytes());
data.extend_from_slice(&0i64.to_be_bytes());
data.push(1); data.extend_from_slice(&3600i64.to_be_bytes()); data.extend_from_slice(&86400i64.to_be_bytes()); data.extend_from_slice(&encode_vint(250));
let result = parse_timestamp_statistics(&data);
assert!(result.is_ok());
let (_, ts_stats) = result.unwrap();
assert_eq!(ts_stats.min_ttl, Some(3600));
assert_eq!(ts_stats.max_ttl, Some(86400));
assert_eq!(ts_stats.rows_with_ttl, Some(250));
}
fn create_test_statistics() -> SSTableStatistics {
SSTableStatistics {
header: StatisticsHeader {
version: 1,
statistics_kind: 3,
data_length: 1024,
metadata1: 0,
metadata2: 0,
metadata3: 0,
checksum: 0x12345678,
table_id: Some([1; 16]),
},
row_stats: RowStatistics {
total_rows: 1000,
live_rows: 900,
tombstone_count: 100,
partition_count: 50,
avg_rows_per_partition: 20.0,
row_size_histogram: vec![],
},
timestamp_stats: TimestampStatistics {
min_timestamp: 1000000,
max_timestamp: Some(2000000),
min_deletion_time: 0,
max_deletion_time: 0,
min_ttl: None,
max_ttl: None,
rows_with_ttl: Some(0),
},
column_stats: vec![],
table_stats: Some(TableStatistics {
disk_size: 1024 * 1024,
uncompressed_size: 2048 * 1024,
compressed_size: 1024 * 1024,
compression_ratio: 0.5,
block_count: 100,
avg_block_size: 1024.0,
index_size: 1024,
bloom_filter_size: 512,
level_count: 1,
}),
partition_stats: Some(PartitionStatistics {
avg_partition_size: 20480.0,
min_partition_size: 1024,
max_partition_size: 1048576,
size_histogram: vec![],
large_partition_percentage: 5.0,
}),
compression_stats: Some(CompressionStatistics {
algorithm: "LZ4".to_string(),
original_size: 2048 * 1024,
compressed_size: 1024 * 1024,
ratio: 0.5,
compression_speed: 100.0,
decompression_speed: 200.0,
compressed_blocks: 100,
}),
metadata: HashMap::new(),
serialization_header_columns: vec![],
serialization_header_partition_keys: vec![],
serialization_header_clustering_keys: vec![],
tombstone_drop_times: vec![],
}
}
}