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}