Skip to main content

cqlite_core/parser/
statistics.rs

1//! Statistics.db parser for Cassandra 5+ SSTable format
2//!
3//! This module provides comprehensive parsing of Statistics.db files which contain
4//! detailed metadata about SSTable contents including row counts, min/max timestamps,
5//! column statistics, and other metadata for efficient query planning.
6
7// Issue #1623/#1637: the legacy Statistics.db parse/serialize subtree is superseded
8// on the prod path by enhanced_statistics_parser and was removed as dead code (J3).
9// What remains here is the shared statistics type definitions reused by the enhanced
10// parser and reader, the authoritative header/timestamp decoders (pinned by tests),
11// and the `StatisticsAnalyzer` report path (wired into `StatisticsReader`).
12use super::vint::parse_vint;
13use nom::{
14    bytes::complete::take,
15    number::complete::{be_i64, be_u32, be_u64, be_u8},
16    IResult,
17};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21/// Statistics.db file header with version and metadata
22/// Updated to support both legacy and enhanced formats
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct StatisticsHeader {
25    /// Format version/type identifier
26    pub version: u32,
27    /// Statistics type/kind identifier (for enhanced format) or table_id (legacy)
28    pub statistics_kind: u32,
29    /// Data length or offset
30    pub data_length: u32,
31    /// Additional metadata field
32    pub metadata1: u32,
33    /// Additional metadata field
34    pub metadata2: u32,
35    /// Additional metadata field
36    pub metadata3: u32,
37    /// CRC32 checksum of the statistics data
38    pub checksum: u32,
39    /// Table UUID for validation (optional for enhanced format)
40    pub table_id: Option<[u8; 16]>,
41}
42
43/// Comprehensive SSTable statistics extracted from Statistics.db
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SSTableStatistics {
46    /// Header information
47    pub header: StatisticsHeader,
48    /// Row count statistics
49    pub row_stats: RowStatistics,
50    /// Timestamp range information
51    pub timestamp_stats: TimestampStatistics,
52    /// Column-level statistics
53    pub column_stats: Vec<ColumnStatistics>,
54    /// Table-level aggregated statistics, or `None` when NOT authoritatively
55    /// parsed from `Statistics.db` (issue #1653). The enhanced (nb) parser does
56    /// not decode these table-level metrics, so it is honestly `None` rather than
57    /// a fabricated all-zero `TableStatistics`; the legacy parser populates
58    /// `Some(..)`.
59    pub table_stats: Option<TableStatistics>,
60    /// Partition size distribution, or `None` when NOT authoritatively parsed from
61    /// `Statistics.db` (issue #1653 — the enhanced parser previously fabricated an
62    /// all-zero `PartitionStatistics`).
63    pub partition_stats: Option<PartitionStatistics>,
64    /// Compression statistics, or `None` when NOT authoritatively parsed from
65    /// `Statistics.db` (issue #1653 — the enhanced parser previously fabricated a
66    /// `CompressionStatistics` with `algorithm: "unknown"` and all-zero metrics).
67    pub compression_stats: Option<CompressionStatistics>,
68    /// Additional metadata
69    pub metadata: HashMap<String, String>,
70    /// SerializationHeader columns (Issue #163)
71    ///
72    /// Column definitions parsed from SerializationHeader embedded in nb-format
73    /// Statistics.db files. Used for schema extraction in V5CompressedLegacy format.
74    /// Empty if SerializationHeader not found in Statistics.db.
75    #[serde(default)]
76    pub serialization_header_columns: Vec<super::header::ColumnInfo>,
77    /// Partition key definitions extracted from SerializationHeader (Issue #195)
78    #[serde(default)]
79    pub serialization_header_partition_keys: Vec<super::header::ColumnInfo>,
80    /// Clustering key definitions extracted from SerializationHeader (Issue #195)
81    #[serde(default)]
82    pub serialization_header_clustering_keys: Vec<super::header::ColumnInfo>,
83    /// Estimated tombstone-drop-times histogram as `(point, count)` pairs,
84    /// decoded best-effort from the STATS component (Issue #1073). Empty when
85    /// the SSTable carries no tombstones or the histogram could not be decoded.
86    #[serde(default)]
87    pub tombstone_drop_times: Vec<(i64, u64)>,
88}
89
90/// Row count and distribution statistics
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct RowStatistics {
93    /// Total number of rows in the SSTable
94    pub total_rows: u64,
95    /// Number of live (non-tombstone) rows
96    pub live_rows: u64,
97    /// Number of tombstone markers
98    pub tombstone_count: u64,
99    /// Estimated number of partitions
100    pub partition_count: u64,
101    /// Average rows per partition
102    pub avg_rows_per_partition: f64,
103    /// Row size distribution histogram
104    pub row_size_histogram: Vec<RowSizeBucket>,
105}
106
107/// Timestamp range and TTL statistics
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct TimestampStatistics {
110    /// Minimum timestamp in the SSTable (microseconds since epoch)
111    pub min_timestamp: i64,
112    /// Maximum timestamp in the SSTable (microseconds since epoch), or `None`
113    /// when it is NOT authoritatively available from `Statistics.db` (issue
114    /// #1653). The enhanced (nb) parser leaves this `None` until the best-effort
115    /// STATS post-pass recovers the real `maxTimestamp` (#1729); a `None` here is
116    /// an honest "unavailable", NOT a fabricated placeholder (it previously
117    /// aliased `min_timestamp`/`i64::MIN`, both of which lied about the max). A
118    /// consumer that needs a real maximum MUST treat `None` as unavailable and
119    /// fail closed (see `write_engine::merge::fully_expired`).
120    pub max_timestamp: Option<i64>,
121    /// Minimum deletion time (for tombstones)
122    pub min_deletion_time: i64,
123    /// Maximum deletion time (for tombstones). Genuinely decoded from STATS by the
124    /// best-effort post-pass (#1073/#1011); before it runs the enhanced parser
125    /// uses the authoritative `NO_DELETION_TIME` (`i64::MAX`) "no deletions
126    /// recorded" value, so a post-pass failure fails CLOSED (never classified
127    /// fully-expired) rather than the old `= min_deletion_time` lie (issue #1653).
128    pub max_deletion_time: i64,
129    /// Minimum TTL value
130    pub min_ttl: Option<i64>,
131    /// Maximum TTL value, or `None` when not authoritatively available (issue
132    /// #1653 — the enhanced parser previously aliased this to `min_ttl`, a lie).
133    pub max_ttl: Option<i64>,
134    /// Number of rows with TTL, or `None` when not authoritatively available from
135    /// `Statistics.db` (issue #1653). The enhanced (nb) parser does not decode a
136    /// per-SSTable rows-with-TTL count, so it is honestly `None` rather than a
137    /// fabricated `0` (which claimed "no rows have a TTL").
138    pub rows_with_ttl: Option<u64>,
139}
140
141/// Per-column statistics for query optimization
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ColumnStatistics {
144    /// Column name
145    pub name: String,
146    /// Column type (CQL type)
147    pub column_type: String,
148    /// Number of non-null values
149    pub value_count: u64,
150    /// Number of null values
151    pub null_count: u64,
152    /// Minimum value (serialized as bytes)
153    pub min_value: Option<Vec<u8>>,
154    /// Maximum value (serialized as bytes)
155    pub max_value: Option<Vec<u8>>,
156    /// Average serialized size in bytes
157    pub avg_size: f64,
158    /// Estimated cardinality (distinct values)
159    pub cardinality: u64,
160    /// Value frequency histogram for common values
161    pub value_histogram: Vec<ValueFrequency>,
162    /// Whether this column has an index
163    pub has_index: bool,
164}
165
166/// Table-level aggregated statistics
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct TableStatistics {
169    /// Total disk space used by the SSTable
170    pub disk_size: u64,
171    /// Uncompressed size
172    pub uncompressed_size: u64,
173    /// Compressed size
174    pub compressed_size: u64,
175    /// Compression ratio
176    pub compression_ratio: f64,
177    /// Number of blocks in the SSTable
178    pub block_count: u64,
179    /// Average block size
180    pub avg_block_size: f64,
181    /// Index size in bytes
182    pub index_size: u64,
183    /// Bloom filter size in bytes
184    pub bloom_filter_size: u64,
185    /// Number of levels in LSM tree
186    pub level_count: u32,
187}
188
189/// Partition size distribution for efficient range queries
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct PartitionStatistics {
192    /// Average partition size in bytes
193    pub avg_partition_size: f64,
194    /// Minimum partition size
195    pub min_partition_size: u64,
196    /// Maximum partition size
197    pub max_partition_size: u64,
198    /// Partition size distribution
199    pub size_histogram: Vec<PartitionSizeBucket>,
200    /// Percentage of large partitions (>1MB)
201    pub large_partition_percentage: f64,
202}
203
204/// Compression algorithm performance statistics
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct CompressionStatistics {
207    /// Compression algorithm used
208    pub algorithm: String,
209    /// Original size before compression
210    pub original_size: u64,
211    /// Compressed size
212    pub compressed_size: u64,
213    /// Compression ratio (compressed/original)
214    pub ratio: f64,
215    /// Compression speed in MB/s
216    pub compression_speed: f64,
217    /// Decompression speed in MB/s
218    pub decompression_speed: f64,
219    /// Number of compressed blocks
220    pub compressed_blocks: u64,
221}
222
223/// Row size distribution bucket
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct RowSizeBucket {
226    /// Size range start (inclusive)
227    pub size_start: u64,
228    /// Size range end (exclusive)
229    pub size_end: u64,
230    /// Number of rows in this bucket
231    pub count: u64,
232    /// Percentage of total rows
233    pub percentage: f64,
234}
235
236/// Value frequency information for column statistics
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ValueFrequency {
239    /// Serialized value (truncated for large values)
240    pub value: Vec<u8>,
241    /// Number of occurrences
242    pub frequency: u64,
243    /// Percentage of total non-null values
244    pub percentage: f64,
245}
246
247/// Partition size distribution bucket
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct PartitionSizeBucket {
250    /// Size range start (inclusive)
251    pub size_start: u64,
252    /// Size range end (exclusive)
253    pub size_end: u64,
254    /// Number of partitions in this bucket
255    pub count: u64,
256    /// Cumulative percentage
257    pub cumulative_percentage: f64,
258}
259
260/// Parse the Statistics.db file header with authoritative format detection.
261///
262/// CQLite targets Cassandra 5.0 (`na`+/`nb` BIG, `oa`/`da` BTI); the supported
263/// header is the **version-4 `nb`** layout (the one the reader open path
264/// exercises via `enhanced_statistics_parser`):
265///
266/// - **Version 4**: 'nb' enhanced-statistics header (Cassandra 5.0+)
267///     - Structure: version(4) + statistics_kind(4) + reserved(4) + data_length(4) +
268///       metadata1(4) + metadata2(4) + metadata3(4) + checksum(4) = 32 bytes
269///     - Authoritative marker: version == 4
270///
271/// This function successfully parses `1..=3` headers (pre-`na`, Cassandra
272/// 3.x/4.x) but those versions are UNSUPPORTED and OUT OF SCOPE per the version
273/// floor — they are REJECTED downstream by `BigVersionGates::from_version` /
274/// `SSTableReader::open` with `Error::UnsupportedVersion`, not here. The `1..=3`
275/// branch exists only so this standalone helper can parse a header structure
276/// without misclassifying a lower version; it SHALL NOT be relied on for
277/// correctness. Other versions (0, 5+) return a parse error.
278pub fn parse_statistics_header(input: &[u8]) -> IResult<&[u8], StatisticsHeader> {
279    let (remaining, version) = be_u32(input)?;
280
281    match version {
282        // nb-format: Cassandra 5.0+ enhanced statistics (version 4).
283        // The authoritative format identifier — no heuristics needed.
284        4 => parse_nb_format_header(remaining, version),
285
286        // Out-of-scope lower versions: parsed only so this standalone helper
287        // fails gracefully; not a supported format (see fn doc / version floor).
288        1..=3 => parse_legacy_format_header(remaining, version),
289
290        // Unknown/unsupported version - fail explicitly
291        // This ensures we never silently misparse corrupt or future formats
292        _ => Err(nom::Err::Error(nom::error::Error::new(
293            input,
294            nom::error::ErrorKind::Verify,
295        ))),
296    }
297}
298
299/// Parse nb-format (version 4) Statistics.db header
300///
301/// Format structure (Cassandra 5.0+):
302/// ```text
303/// [0..4]   version: u32          = 4 (nb-format identifier)
304/// [4..8]   statistics_kind: u32  (statistics type/kind identifier)
305/// [8..12]  reserved: u32         (reserved field, typically 0)
306/// [12..16] data_length: u32      (length of statistics data section)
307/// [16..20] metadata1: u32        (metadata field 1)
308/// [20..24] metadata2: u32        (metadata field 2)
309/// [24..28] metadata3: u32        (metadata field 3)
310/// [28..32] checksum: u32         (CRC32 checksum)
311/// ```
312fn parse_nb_format_header(input: &[u8], version: u32) -> IResult<&[u8], StatisticsHeader> {
313    let (input, statistics_kind) = be_u32(input)?;
314    let (input, _reserved) = be_u32(input)?;
315    let (input, data_length) = be_u32(input)?;
316    let (input, metadata1) = be_u32(input)?;
317    let (input, metadata2) = be_u32(input)?;
318    let (input, metadata3) = be_u32(input)?;
319    let (input, checksum) = be_u32(input)?;
320
321    Ok((
322        input,
323        StatisticsHeader {
324            version,
325            statistics_kind,
326            data_length,
327            metadata1,
328            metadata2,
329            metadata3,
330            checksum,
331            table_id: None, // nb-format does not include table_id in header
332        },
333    ))
334}
335
336/// Parse a version-1..=3 Statistics.db header.
337///
338/// OUT OF SCOPE (pre-`na`, Cassandra 3.x/4.x). This parser exists only so
339/// [`parse_statistics_header`] fails gracefully on a lower version rather than
340/// misclassifying it as `nb`; CQLite does not open pre-`na` SSTables and this
341/// path is not covered for correctness (version floor).
342///
343/// Byte layout of that header:
344/// ```text
345/// [0..4]   version: u32          = 1, 2, or 3
346/// [4..20]  table_id: [u8; 16]    (UUID of the table)
347/// [20..24] section_count: u32    (number of statistics sections)
348/// [24..32] file_size: u64        (total file size)
349/// [32..36] checksum: u32         (CRC32 checksum)
350/// ```
351fn parse_legacy_format_header(input: &[u8], version: u32) -> IResult<&[u8], StatisticsHeader> {
352    let (input, table_id_raw) = take(16u8)(input)?;
353    let mut table_id_array = [0u8; 16];
354    table_id_array.copy_from_slice(table_id_raw);
355
356    let (input, section_count) = be_u32(input)?;
357    let (input, file_size) = be_u64(input)?;
358    let (input, checksum) = be_u32(input)?;
359
360    Ok((
361        input,
362        StatisticsHeader {
363            version,
364            statistics_kind: 0, // Not used in legacy format
365            data_length: section_count,
366            metadata1: (file_size >> 32) as u32,
367            metadata2: file_size as u32,
368            metadata3: 0,
369            checksum,
370            table_id: Some(table_id_array),
371        },
372    ))
373}
374
375/// Parse timestamp range statistics
376pub fn parse_timestamp_statistics(input: &[u8]) -> IResult<&[u8], TimestampStatistics> {
377    let (input, min_timestamp) = be_i64(input)?;
378    let (input, max_timestamp) = be_i64(input)?;
379    let (input, min_deletion_time) = be_i64(input)?;
380    let (input, max_deletion_time) = be_i64(input)?;
381    let (input, has_ttl) = be_u8(input)?;
382    let (input, min_ttl, max_ttl, rows_with_ttl) = if has_ttl != 0 {
383        let (input, min_ttl) = be_i64(input)?;
384        let (input, max_ttl) = be_i64(input)?;
385        let (input, rows_with_ttl) = parse_vint_as_u64(input)?;
386        (input, Some(min_ttl), Some(max_ttl), rows_with_ttl)
387    } else {
388        (input, None, None, 0)
389    };
390
391    Ok((
392        input,
393        TimestampStatistics {
394            min_timestamp,
395            // Legacy format genuinely parses `max_timestamp` and `rows_with_ttl`
396            // from fixed-width fields (issue #1653); the enhanced parser leaves
397            // them `None` when unavailable. But Cassandra seeds its timestamp
398            // `MinMaxLongTracker` max with `Long.MIN_VALUE`, so an SSTable that
399            // recorded no live write timestamp serializes that `i64::MIN`
400            // sentinel verbatim. Decode it to `None` via the SAME helper the
401            // enhanced parser uses, so a legacy parse can never leak the "no max
402            // recorded" sentinel as a real maximum (roborev #1653 legacy leak).
403            max_timestamp: crate::parser::repair_metadata::decode_max_timestamp(max_timestamp),
404            min_deletion_time,
405            max_deletion_time,
406            min_ttl,
407            max_ttl,
408            rows_with_ttl: Some(rows_with_ttl),
409        },
410    ))
411}
412
413/// Helper function to parse VInt as u64
414fn parse_vint_as_u64(input: &[u8]) -> IResult<&[u8], u64> {
415    let (input, value) = parse_vint(input)?;
416    Ok((input, value as u64))
417}
418
419/// Whether `avg_rows_per_partition` holds a REAL derived value rather than the
420/// documented #1325 unavailable sentinel `0.0`.
421///
422/// The average is `total_rows / partition_count`; the parser leaves it `0.0`
423/// whenever EITHER `total_rows` is not authoritatively reachable from STATS OR
424/// `partition_count == 0`. So the value is only real when BOTH counts are
425/// positive. Single-sourced here and reused by every consumer (recommendations
426/// in this module and the report renderer in `statistics_reader`) to keep the
427/// availability condition from drifting. No heuristic (#28); see #1352.
428pub(crate) fn avg_rows_available(stats: &SSTableStatistics) -> bool {
429    stats.row_stats.total_rows > 0 && stats.row_stats.partition_count > 0
430}
431
432/// Statistics analyzer for enhanced reporting
433pub struct StatisticsAnalyzer;
434
435impl StatisticsAnalyzer {
436    /// Analyze statistics and generate human-readable summary
437    pub fn analyze(stats: &SSTableStatistics) -> StatisticsSummary {
438        let data_efficiency = Self::calculate_data_efficiency(stats);
439        let query_performance_hints = Self::generate_query_hints(stats);
440        let storage_recommendations = Self::generate_storage_recommendations(stats);
441        let health_score = Self::calculate_health_score(stats);
442
443        StatisticsSummary {
444            total_rows: stats.row_stats.total_rows,
445            // `None` when not authoritatively available; see `live_data_percentage`.
446            live_data_percentage: Self::live_data_percentage(stats),
447            // `None` when compression stats were not authoritatively parsed
448            // (issue #1653) rather than a fabricated `0% × 100`.
449            compression_efficiency: stats.compression_stats.as_ref().map(|c| c.ratio * 100.0),
450            timestamp_range_days: Self::calculate_timestamp_range_days(stats),
451            // `None` when partition stats were not authoritatively parsed
452            // (issue #1653) rather than a fabricated `0 MB`.
453            largest_partition_mb: stats
454                .partition_stats
455                .as_ref()
456                .map(|p| p.max_partition_size as f64 / 1_048_576.0),
457            data_efficiency,
458            query_performance_hints,
459            storage_recommendations,
460            health_score,
461        }
462    }
463
464    /// Live-data percentage, or `None` when it is not authoritatively available.
465    ///
466    /// `live_rows == 0` is the documented #1325 sentinel meaning "not
467    /// authoritatively derivable from STATS" (STATS has no per-SSTable live-row
468    /// count), so we return `None` rather than a misleading concrete `0.00%`.
469    /// A genuinely fully-tombstoned SSTable also reads as unavailable here —
470    /// honest, since we cannot distinguish it from "unknown" until the
471    /// `RowStatistics` representation is redesigned (#1352). No heuristic (#28).
472    fn live_data_percentage(stats: &SSTableStatistics) -> Option<f64> {
473        if stats.row_stats.live_rows == 0 || stats.row_stats.total_rows == 0 {
474            return None;
475        }
476        Some((stats.row_stats.live_rows as f64 / stats.row_stats.total_rows as f64) * 100.0)
477    }
478
479    /// Overall data efficiency, or `None` when it is not authoritatively
480    /// derivable.
481    ///
482    /// Data efficiency blends the live-row ratio with compression and partition
483    /// efficiency, so it can only be computed when the live ratio is available.
484    /// `live_rows == 0` is the documented #1325 sentinel meaning "not
485    /// authoritatively available from STATS" (STATS has no per-SSTable live-row
486    /// count), and `total_rows == 0` would make the ratio `NaN`; in either case
487    /// we return `None` rather than a misleading concrete number. This reuses
488    /// the exact availability check of `live_data_percentage`. No heuristic
489    /// (#28); representation redesign is tracked by #1352.
490    fn calculate_data_efficiency(stats: &SSTableStatistics) -> Option<f64> {
491        if stats.row_stats.live_rows == 0 || stats.row_stats.total_rows == 0 {
492            return None;
493        }
494        // Data efficiency blends the live ratio with compression and partition
495        // efficiency. When either compression or partition statistics were NOT
496        // authoritatively parsed (issue #1653 — the enhanced nb parser leaves them
497        // `None`) the blend cannot be computed honestly, so return `None` rather
498        // than substituting a fabricated default (no-heuristics mandate #28).
499        let (Some(compression), Some(partition)) = (
500            stats.compression_stats.as_ref(),
501            stats.partition_stats.as_ref(),
502        ) else {
503            return None;
504        };
505        let live_ratio = stats.row_stats.live_rows as f64 / stats.row_stats.total_rows as f64;
506        let compression_ratio = compression.ratio;
507        let partition_efficiency = 1.0 - (partition.large_partition_percentage / 100.0);
508
509        Some((live_ratio + compression_ratio + partition_efficiency) / 3.0 * 100.0)
510    }
511
512    fn generate_query_hints(stats: &SSTableStatistics) -> Vec<String> {
513        let mut hints = Vec::new();
514
515        // Partition/compression hints only fire when those statistics were
516        // authoritatively parsed (issue #1653): a `None` (enhanced nb parser)
517        // means "unavailable", so we emit no hint rather than one derived from a
518        // fabricated default. No heuristic (#28).
519        if let Some(partition) = stats.partition_stats.as_ref() {
520            if partition.large_partition_percentage > 10.0 {
521                hints.push("Consider reviewing partition key design - high percentage of large partitions detected".to_string());
522            }
523        }
524
525        // The "high tombstone ratio" hint compares tombstones against the live
526        // row count. When `live_rows == 0` that is the documented #1325 sentinel
527        // for "not authoritatively available from STATS" (not a measured zero),
528        // so `live_rows / 4 == 0` would make ANY tombstone trip the hint. Skip
529        // the hint entirely when the live count is unavailable rather than emit
530        // a misleading recommendation. No heuristic (#28); see #1352.
531        if stats.row_stats.live_rows > 0
532            && stats.row_stats.tombstone_count > stats.row_stats.live_rows / 4
533        {
534            hints.push("High tombstone ratio - consider running compaction".to_string());
535        }
536
537        if let Some(table) = stats.table_stats.as_ref() {
538            if table.compression_ratio < 0.5 {
539                hints.push("Low compression ratio - data may not be well-suited for current compression algorithm".to_string());
540            }
541        }
542
543        hints
544    }
545
546    fn generate_storage_recommendations(stats: &SSTableStatistics) -> Vec<String> {
547        let mut recommendations = Vec::new();
548
549        // Only recommend on an authoritatively-parsed disk size (issue #1653): a
550        // `None` `table_stats` (enhanced nb parser) is "unavailable", not a
551        // measured tiny SSTable.
552        if let Some(table) = stats.table_stats.as_ref() {
553            if table.disk_size > 1_073_741_824 {
554                recommendations
555                    .push("Large SSTable detected - consider more frequent compaction".to_string());
556            }
557        }
558
559        // `avg_rows_per_partition == 0.0` is the documented #1325 unavailable
560        // sentinel: the parser leaves it 0.0 whenever `total_rows` is not
561        // authoritatively reachable from STATS OR `partition_count == 0` (the
562        // average is `total_rows / partition_count`). Only emit the granularity
563        // recommendation when the average is a REAL derived value — i.e. BOTH
564        // `total_rows > 0` AND `partition_count > 0` (so the average was actually
565        // computed). Otherwise the sentinel `0.0 < 10.0` would always trip this
566        // hint (e.g. on nb SSTables whose gated walk can't reach `totalRows`, or
567        // when `partition_count == 0`), a misleading recommendation from a
568        // non-value. No heuristic (#28); see #1352.
569        if avg_rows_available(stats) && stats.row_stats.avg_rows_per_partition < 10.0 {
570            recommendations.push(
571                "Low average rows per partition - partition key may be too granular".to_string(),
572            );
573        }
574
575        recommendations
576    }
577
578    fn calculate_health_score(stats: &SSTableStatistics) -> f64 {
579        let mut score = 100.0;
580
581        // Deduct for high tombstone ratio. This derives from `total_rows` (an
582        // authoritative STATS count), NOT the #1325 `live_rows` unavailable
583        // sentinel, so it stays a concrete score. Guard `total_rows == 0` so the
584        // ratio does not become `NaN` (which would poison the whole score).
585        if stats.row_stats.total_rows > 0 {
586            let tombstone_ratio =
587                stats.row_stats.tombstone_count as f64 / stats.row_stats.total_rows as f64;
588            score -= tombstone_ratio * 30.0;
589        }
590
591        // Deduct for poor compression / large partitions ONLY when those
592        // statistics were authoritatively parsed (issue #1653). A `None`
593        // (enhanced nb parser) is "unavailable", so we do not deduct from a
594        // fabricated default — the score reflects only what is known. No
595        // heuristic (#28).
596        if let Some(compression) = stats.compression_stats.as_ref() {
597            if compression.ratio < 0.5 {
598                score -= 20.0;
599            }
600        }
601
602        if let Some(partition) = stats.partition_stats.as_ref() {
603            score -= partition.large_partition_percentage;
604        }
605
606        score.max(0.0)
607    }
608
609    fn calculate_timestamp_range_days(stats: &SSTableStatistics) -> f64 {
610        // Fail-closed (#1729/#1653): `max_timestamp` is `None` when the
611        // authoritative maxTimestamp is unavailable. When the max is unavailable —
612        // or is somehow below the min — we cannot compute a real range, so report
613        // 0.0 rather than an underflowing/garbage span.
614        let min = stats.timestamp_stats.min_timestamp;
615        let max = match stats.timestamp_stats.max_timestamp {
616            Some(max) if max >= min => max,
617            _ => return 0.0,
618        };
619        // `max >= min` here, so the difference is non-negative and cannot
620        // overflow i64 for realistic timestamps; use a checked subtraction to
621        // stay defensive against pathological inputs.
622        let range_micros = max.saturating_sub(min);
623        range_micros as f64 / (1_000_000.0 * 60.0 * 60.0 * 24.0)
624    }
625}
626
627/// Human-readable statistics summary
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct StatisticsSummary {
630    pub total_rows: u64,
631    /// Percentage of live (non-tombstoned) data, or `None` when not
632    /// authoritatively available (the #1325 `live_rows == 0` sentinel; see
633    /// `StatisticsAnalyzer::live_data_percentage` and #1352).
634    pub live_data_percentage: Option<f64>,
635    /// Compression efficiency (ratio × 100), or `None` when compression
636    /// statistics are not authoritatively available (issue #1653 — the enhanced
637    /// nb parser does not decode them; see `SSTableStatistics::compression_stats`).
638    pub compression_efficiency: Option<f64>,
639    pub timestamp_range_days: f64,
640    /// Largest partition size in MB, or `None` when partition statistics are not
641    /// authoritatively available (issue #1653; see
642    /// `SSTableStatistics::partition_stats`).
643    pub largest_partition_mb: Option<f64>,
644    /// Blended data-efficiency score, or `None` when the live-row ratio is not
645    /// authoritatively available (the #1325 `live_rows == 0` sentinel, or
646    /// `total_rows == 0`); see `StatisticsAnalyzer::calculate_data_efficiency`
647    /// and #1352.
648    pub data_efficiency: Option<f64>,
649    pub query_performance_hints: Vec<String>,
650    pub storage_recommendations: Vec<String>,
651    pub health_score: f64,
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn test_statistics_header_parsing() {
660        let test_data = vec![
661            0x00, 0x00, 0x00, 0x01, // version = 1
662            // table_id (16 bytes)
663            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
664            0x0F, 0x10, 0x00, 0x00, 0x00, 0x05, // section_count = 5
665            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, // file_size = 4096
666            0x12, 0x34, 0x56, 0x78, // checksum
667        ];
668
669        let result = parse_statistics_header(&test_data);
670        assert!(result.is_ok());
671
672        let (_, header) = result.unwrap();
673        assert_eq!(header.version, 1);
674        // assert_eq!(header.section_count, 5); // Field not available
675        // assert_eq!(header.file_size, 4096); // Field not available
676        assert_eq!(header.checksum, 0x12345678);
677    }
678
679    #[test]
680    fn test_nb_format_authoritative_detection() {
681        // nb-format (version 4) - should parse as nb-format
682        let nb_data = vec![
683            0x00, 0x00, 0x00, 0x04, // version = 4 (authoritative nb-format marker)
684            0x26, 0x29, 0x1b, 0x05, // statistics_kind
685            0x00, 0x00, 0x00, 0x00, // reserved
686            0x00, 0x00, 0x00, 0x2c, // data_length = 44
687            0x00, 0x00, 0x00, 0x01, // metadata1 = 1
688            0x00, 0x00, 0x00, 0x65, // metadata2 = 101
689            0x00, 0x00, 0x00, 0x02, // metadata3 = 2
690            0x00, 0x00, 0x14, 0xd4, // checksum = 5332
691        ];
692
693        let result = parse_statistics_header(&nb_data);
694        assert!(result.is_ok());
695
696        let (_, header) = result.unwrap();
697        assert_eq!(header.version, 4);
698        assert_eq!(header.statistics_kind, 0x26291b05);
699        assert_eq!(header.data_length, 44);
700        assert!(header.table_id.is_none()); // nb-format has no table_id
701    }
702
703    #[test]
704    fn test_legacy_format_authoritative_detection() {
705        // Legacy format (version 2) - should parse as legacy
706        let legacy_data = vec![
707            0x00, 0x00, 0x00, 0x02, // version = 2 (authoritative legacy marker)
708            // table_id (16 bytes)
709            0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
710            0xFF, 0x00, 0x00, 0x00, 0x00, 0x0A, // section_count = 10
711            0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, // file_size = 65536
712            0xAB, 0xCD, 0xEF, 0x12, // checksum
713        ];
714
715        let result = parse_statistics_header(&legacy_data);
716        assert!(result.is_ok());
717
718        let (_, header) = result.unwrap();
719        assert_eq!(header.version, 2);
720        assert_eq!(header.statistics_kind, 0); // legacy format doesn't use this
721        assert!(header.table_id.is_some()); // legacy format has table_id
722    }
723
724    #[test]
725    fn test_unsupported_version_rejection() {
726        // Version 0 - should be rejected
727        let invalid_v0 = vec![
728            0x00, 0x00, 0x00, 0x00, // version = 0 (invalid)
729            0x00, 0x00, 0x00, 0x00, // ...rest doesn't matter
730        ];
731        assert!(parse_statistics_header(&invalid_v0).is_err());
732
733        // Version 5 - should be rejected (future/unknown version)
734        let invalid_v5 = vec![
735            0x00, 0x00, 0x00, 0x05, // version = 5 (unsupported)
736            0x00, 0x00, 0x00, 0x00, // ...rest doesn't matter
737        ];
738        assert!(parse_statistics_header(&invalid_v5).is_err());
739
740        // Version 255 - should be rejected
741        let invalid_v255 = vec![
742            0x00, 0x00, 0x00, 0xFF, // version = 255 (unsupported)
743            0x00, 0x00, 0x00, 0x00, // ...rest doesn't matter
744        ];
745        assert!(parse_statistics_header(&invalid_v255).is_err());
746    }
747
748    #[test]
749    fn test_no_heuristics_version_4_with_short_input() {
750        // Previous implementation used heuristic: version == 4 && input.len() >= 28
751        // New implementation uses ONLY version number - no length check heuristic
752        // This test ensures we don't fall back to legacy parsing with short input
753
754        let short_nb_data = vec![
755            0x00, 0x00, 0x00, 0x04, // version = 4 (authoritative nb-format)
756            0x26, 0x29, 0x1b, 0x05, // statistics_kind
757            0x00, 0x00, 0x00, 0x00, // reserved
758            0x00, 0x00, 0x00,
759            0x2c, // data_length = 44
760                  // Missing remaining fields - should fail parsing, not switch formats
761        ];
762
763        let result = parse_statistics_header(&short_nb_data);
764        // Should fail because version 4 DEFINITIVELY means nb-format
765        // and nb-format requires 32 bytes. This is NOT a heuristic,
766        // it's the authoritative format specification.
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_statistics_analyzer() {
772        let stats = create_test_statistics();
773        let summary = StatisticsAnalyzer::analyze(&stats);
774
775        assert!(summary.total_rows > 0);
776        assert!(summary.health_score >= 0.0 && summary.health_score <= 100.0);
777        let live_pct = summary
778            .live_data_percentage
779            .expect("fixture has authoritative live_rows > 0");
780        assert!((0.0..=100.0).contains(&live_pct));
781    }
782
783    /// #1325 roborev finding: when `live_rows` is the documented "not
784    /// authoritatively available from STATS" sentinel (0) but `total_rows` is a
785    /// real authoritative count, the analyzer MUST report live-data% as
786    /// unavailable (`None`) rather than a misleading concrete `0.00%`. Other
787    /// authoritative fields (`total_rows`) stay intact. See #1352.
788    #[test]
789    fn test_live_data_percentage_unavailable_when_live_rows_sentinel() {
790        let mut stats = create_test_statistics();
791        stats.row_stats.total_rows = 1000; // real authoritative count
792        stats.row_stats.live_rows = 0; // documented "unavailable" sentinel
793
794        let summary = StatisticsAnalyzer::analyze(&stats);
795
796        assert_eq!(
797            summary.live_data_percentage, None,
798            "live_rows==0 sentinel with total_rows>0 must report None, not 0.00%"
799        );
800        // Data efficiency is derived from the live ratio, so it must be
801        // unavailable under the same sentinel (not an artificially low number).
802        assert_eq!(
803            summary.data_efficiency, None,
804            "data_efficiency must be None when live_rows is the unavailable sentinel"
805        );
806        // Authoritative counts are unaffected.
807        assert_eq!(summary.total_rows, 1000);
808        // Health score derives from total_rows (authoritative), not live_rows,
809        // so it stays a concrete, finite value.
810        assert!(
811            summary.health_score.is_finite(),
812            "health_score must stay finite when live_rows is the sentinel"
813        );
814    }
815
816    /// #1325 sweep: the "high tombstone ratio" query hint compares tombstones
817    /// against `live_rows`. When `live_rows == 0` (unavailable sentinel), the
818    /// old `live_rows / 4 == 0` comparison would trip the hint for ANY
819    /// tombstone. It must be suppressed instead. No heuristic (#28); see #1352.
820    #[test]
821    fn test_query_hint_suppressed_when_live_rows_sentinel() {
822        let mut stats = create_test_statistics();
823        stats.row_stats.total_rows = 1000;
824        stats.row_stats.live_rows = 0; // unavailable sentinel
825        stats.row_stats.tombstone_count = 500; // would trip vs. live_rows/4 == 0
826
827        let summary = StatisticsAnalyzer::analyze(&stats);
828
829        assert!(
830            !summary
831                .query_performance_hints
832                .iter()
833                .any(|h| h.contains("tombstone")),
834            "tombstone hint must be suppressed when live_rows is the unavailable sentinel"
835        );
836    }
837
838    /// #1325 sweep: when `live_rows` is a real count, the tombstone hint and
839    /// data-efficiency stay concrete (non-vacuous positive path).
840    #[test]
841    fn test_derived_metrics_available_when_live_rows_present() {
842        let mut stats = create_test_statistics();
843        stats.row_stats.total_rows = 1000;
844        stats.row_stats.live_rows = 100; // real count; live_rows/4 == 25
845        stats.row_stats.tombstone_count = 500; // 500 > 25 -> hint fires
846
847        let summary = StatisticsAnalyzer::analyze(&stats);
848
849        assert!(
850            summary.live_data_percentage.is_some(),
851            "live_data_percentage must be Some when live_rows > 0"
852        );
853        let eff = summary
854            .data_efficiency
855            .expect("data_efficiency must be Some when live_rows > 0");
856        assert!(eff.is_finite());
857        assert!(
858            summary
859                .query_performance_hints
860                .iter()
861                .any(|h| h.contains("tombstone")),
862            "tombstone hint must fire when tombstones exceed live_rows/4"
863        );
864    }
865
866    /// #1325 sweep: health score must not become `NaN` when `total_rows == 0`
867    /// (guard the div-by-zero in the tombstone-ratio deduction).
868    #[test]
869    fn test_health_score_finite_when_total_rows_zero() {
870        let mut stats = create_test_statistics();
871        stats.row_stats.total_rows = 0;
872        stats.row_stats.live_rows = 0;
873        stats.row_stats.tombstone_count = 0;
874
875        let summary = StatisticsAnalyzer::analyze(&stats);
876
877        assert!(
878            summary.health_score.is_finite(),
879            "health_score must be finite (not NaN) when total_rows == 0"
880        );
881        assert!((0.0..=100.0).contains(&summary.health_score));
882    }
883
884    /// #1325 roborev finding: the "partition key may be too granular" storage
885    /// recommendation compares `avg_rows_per_partition < 10.0`. When
886    /// `total_rows == 0` the parser leaves `avg_rows_per_partition == 0.0` as the
887    /// documented unavailable sentinel, so `0.0 < 10.0` would ALWAYS trip the
888    /// recommendation on nb SSTables whose gated walk cannot reach `totalRows`.
889    /// It must be suppressed. No heuristic (#28); see #1352.
890    #[test]
891    fn test_granularity_recommendation_suppressed_when_avg_rows_sentinel() {
892        let mut stats = create_test_statistics();
893        // Unavailable sentinel: no authoritative total_rows -> avg left 0.0.
894        stats.row_stats.total_rows = 0;
895        stats.row_stats.avg_rows_per_partition = 0.0;
896        // Keep disk_size small so the "large SSTable" recommendation does not fire.
897        if let Some(t) = stats.table_stats.as_mut() {
898            t.disk_size = 1024;
899        }
900
901        let summary = StatisticsAnalyzer::analyze(&stats);
902
903        assert!(
904            !summary
905                .storage_recommendations
906                .iter()
907                .any(|r| r.contains("too granular")),
908            "granularity recommendation must be suppressed when avg_rows_per_partition \
909             is the unavailable sentinel (total_rows == 0)"
910        );
911    }
912
913    /// #1325 sweep (non-vacuous positive path): with a REAL low average
914    /// (total_rows > 0), the granularity recommendation still fires.
915    #[test]
916    fn test_granularity_recommendation_fires_when_avg_rows_real_and_low() {
917        let mut stats = create_test_statistics();
918        stats.row_stats.total_rows = 8; // real authoritative count
919        stats.row_stats.partition_count = 4;
920        stats.row_stats.avg_rows_per_partition = 2.0; // real, genuinely low (< 10)
921        if let Some(t) = stats.table_stats.as_mut() {
922            t.disk_size = 1024;
923        }
924
925        let summary = StatisticsAnalyzer::analyze(&stats);
926
927        assert!(
928            summary
929                .storage_recommendations
930                .iter()
931                .any(|r| r.contains("too granular")),
932            "granularity recommendation must fire for a REAL low avg_rows_per_partition"
933        );
934    }
935
936    /// #1325 sweep (non-vacuous): with a real HIGH average the recommendation
937    /// does NOT fire — proving the guard did not just always-suppress.
938    #[test]
939    fn test_granularity_recommendation_absent_when_avg_rows_real_and_high() {
940        let mut stats = create_test_statistics();
941        stats.row_stats.total_rows = 1000; // real; default avg is 20.0 (>= 10)
942        if let Some(t) = stats.table_stats.as_mut() {
943            t.disk_size = 1024;
944        }
945
946        let summary = StatisticsAnalyzer::analyze(&stats);
947
948        assert!(
949            !summary
950                .storage_recommendations
951                .iter()
952                .any(|r| r.contains("too granular")),
953            "granularity recommendation must NOT fire for a REAL high avg_rows_per_partition"
954        );
955    }
956
957    /// #1374 roborev finding: `avg_rows_per_partition` is `total_rows /
958    /// partition_count`, so the `0.0` unavailable sentinel also occurs when
959    /// `total_rows > 0` but `partition_count == 0`. The availability guard must
960    /// require BOTH counts positive; a stats object with real `total_rows` but
961    /// zero partitions must still SUPPRESS the "too granular" recommendation.
962    #[test]
963    fn test_granularity_recommendation_suppressed_when_partition_count_zero() {
964        let mut stats = create_test_statistics();
965        stats.row_stats.total_rows = 8; // real authoritative count
966        stats.row_stats.partition_count = 0; // but no partitions -> avg is sentinel
967        stats.row_stats.avg_rows_per_partition = 0.0;
968        if let Some(t) = stats.table_stats.as_mut() {
969            t.disk_size = 1024;
970        }
971
972        // The shared availability helper must report unavailable.
973        assert!(
974            !avg_rows_available(&stats),
975            "avg_rows must be unavailable when partition_count == 0 despite total_rows > 0"
976        );
977
978        let summary = StatisticsAnalyzer::analyze(&stats);
979
980        assert!(
981            !summary
982                .storage_recommendations
983                .iter()
984                .any(|r| r.contains("too granular")),
985            "granularity recommendation must be suppressed when partition_count == 0 \
986             (avg_rows_per_partition is the unavailable sentinel)"
987        );
988    }
989
990    #[test]
991    fn test_parse_timestamp_statistics_no_ttl() {
992        let mut data = Vec::new();
993        data.extend_from_slice(&1000000i64.to_be_bytes()); // min_timestamp
994        data.extend_from_slice(&2000000i64.to_be_bytes()); // max_timestamp
995        data.extend_from_slice(&0i64.to_be_bytes()); // min_deletion_time
996        data.extend_from_slice(&0i64.to_be_bytes()); // max_deletion_time
997        data.push(0); // has_ttl = false
998
999        let result = parse_timestamp_statistics(&data);
1000        assert!(result.is_ok());
1001
1002        let (remaining, ts_stats) = result.unwrap();
1003        assert!(remaining.is_empty());
1004        assert_eq!(ts_stats.min_timestamp, 1000000);
1005        // Legacy parse genuinely reads these → `Some(..)` (issue #1653).
1006        assert_eq!(ts_stats.max_timestamp, Some(2000000));
1007        assert_eq!(ts_stats.min_deletion_time, 0);
1008        assert_eq!(ts_stats.max_deletion_time, 0);
1009        assert!(ts_stats.min_ttl.is_none());
1010        assert!(ts_stats.max_ttl.is_none());
1011        assert_eq!(ts_stats.rows_with_ttl, Some(0));
1012    }
1013
1014    #[test]
1015    fn test_parse_timestamp_statistics_with_ttl() {
1016        use super::super::vint::encode_vint;
1017
1018        let mut data = Vec::new();
1019        data.extend_from_slice(&1000000i64.to_be_bytes());
1020        data.extend_from_slice(&2000000i64.to_be_bytes());
1021        data.extend_from_slice(&0i64.to_be_bytes());
1022        data.extend_from_slice(&0i64.to_be_bytes());
1023        data.push(1); // has_ttl = true
1024        data.extend_from_slice(&3600i64.to_be_bytes()); // min_ttl
1025        data.extend_from_slice(&86400i64.to_be_bytes()); // max_ttl
1026        data.extend_from_slice(&encode_vint(250)); // rows_with_ttl
1027
1028        let result = parse_timestamp_statistics(&data);
1029        assert!(result.is_ok());
1030
1031        let (_, ts_stats) = result.unwrap();
1032        assert_eq!(ts_stats.min_ttl, Some(3600));
1033        assert_eq!(ts_stats.max_ttl, Some(86400));
1034        assert_eq!(ts_stats.rows_with_ttl, Some(250));
1035    }
1036
1037    fn create_test_statistics() -> SSTableStatistics {
1038        SSTableStatistics {
1039            header: StatisticsHeader {
1040                version: 1,
1041                statistics_kind: 3,
1042                data_length: 1024,
1043                metadata1: 0,
1044                metadata2: 0,
1045                metadata3: 0,
1046                checksum: 0x12345678,
1047                table_id: Some([1; 16]),
1048            },
1049            row_stats: RowStatistics {
1050                total_rows: 1000,
1051                live_rows: 900,
1052                tombstone_count: 100,
1053                partition_count: 50,
1054                avg_rows_per_partition: 20.0,
1055                row_size_histogram: vec![],
1056            },
1057            timestamp_stats: TimestampStatistics {
1058                min_timestamp: 1000000,
1059                max_timestamp: Some(2000000),
1060                min_deletion_time: 0,
1061                max_deletion_time: 0,
1062                min_ttl: None,
1063                max_ttl: None,
1064                rows_with_ttl: Some(0),
1065            },
1066            column_stats: vec![],
1067            table_stats: Some(TableStatistics {
1068                disk_size: 1024 * 1024,
1069                uncompressed_size: 2048 * 1024,
1070                compressed_size: 1024 * 1024,
1071                compression_ratio: 0.5,
1072                block_count: 100,
1073                avg_block_size: 1024.0,
1074                index_size: 1024,
1075                bloom_filter_size: 512,
1076                level_count: 1,
1077            }),
1078            partition_stats: Some(PartitionStatistics {
1079                avg_partition_size: 20480.0,
1080                min_partition_size: 1024,
1081                max_partition_size: 1048576,
1082                size_histogram: vec![],
1083                large_partition_percentage: 5.0,
1084            }),
1085            compression_stats: Some(CompressionStatistics {
1086                algorithm: "LZ4".to_string(),
1087                original_size: 2048 * 1024,
1088                compressed_size: 1024 * 1024,
1089                ratio: 0.5,
1090                compression_speed: 100.0,
1091                decompression_speed: 200.0,
1092                compressed_blocks: 100,
1093            }),
1094            metadata: HashMap::new(),
1095            serialization_header_columns: vec![],
1096            serialization_header_partition_keys: vec![],
1097            serialization_header_clustering_keys: vec![],
1098            tombstone_drop_times: vec![],
1099        }
1100    }
1101}