Skip to main content

cqlite_core/storage/sstable/
statistics_reader.rs

1//! Statistics.db file reader for enhanced SSTable metadata
2//!
3//! This module provides a high-level interface for reading and analyzing
4//! Statistics.db files that accompany SSTable Data.db files in Cassandra 5+.
5
6use crate::{
7    error::{Error, Result},
8    parser::enhanced_statistics_parser::parse_statistics_with_fallback,
9    parser::statistics::{SSTableStatistics, StatisticsAnalyzer, StatisticsSummary},
10    platform::Platform,
11};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use tokio::fs::File;
15use tokio::io::AsyncReadExt;
16
17/// Calculate CRC32 checksum for data validation
18fn crc32_checksum(data: &[u8]) -> u32 {
19    let mut crc = 0xffff_ffff_u32;
20
21    for &byte in data {
22        crc ^= byte as u32;
23        for _ in 0..8 {
24            if crc & 1 != 0 {
25                crc = (crc >> 1) ^ 0xedb8_8320_u32;
26            } else {
27                crc >>= 1;
28            }
29        }
30    }
31
32    !crc
33}
34
35/// High-level Statistics.db file reader
36pub struct StatisticsReader {
37    /// Path to the Statistics.db file
38    file_path: PathBuf,
39    /// Parsed statistics data
40    statistics: SSTableStatistics,
41    /// Platform abstraction for file operations
42    #[allow(dead_code)]
43    platform: Arc<Platform>,
44}
45
46impl StatisticsReader {
47    /// Open and parse a Statistics.db file
48    pub async fn open(path: &Path, platform: Arc<Platform>) -> Result<Self> {
49        // Derive the format gates from the component filename (e.g.
50        // `da-1-bti-Statistics.db` vs `nb-1-big-Statistics.db`) so the best-effort
51        // STATS-extras decode (max local deletion time + tombstone-drop histogram,
52        // #1073) interprets the version-sensitive fields correctly: the modern
53        // (oa/da) `u32::MAX` no-deletion sentinel and the 12-byte modern histogram
54        // bin width differ from the legacy (nb) signed-i32 / 16-byte forms. When the
55        // filename does not parse, fall back to None (nb-compatible defaults).
56        //
57        // #1249 (spec R1): reject below-floor versions BEFORE *any* filesystem
58        // access. Gates derive solely from the filename string (no file I/O), so
59        // this is the earliest point the na+ floor can be enforced: a parsed-but-
60        // below-floor version is propagated as a FATAL `Error::UnsupportedVersion`
61        // here — before the existence check, `File::open`, and `read_to_end` —
62        // instead of returning `NotFound` after touching the filesystem or
63        // silently degrading to nb-compatible defaults (mirrors
64        // `SSTableReader::open_inner` in reader/mod.rs, which gates before
65        // `tokio::fs::metadata`). Only a genuinely unparseable / structurally-
66        // malformed descriptor falls back to None and proceeds to the FS checks.
67        let gates = match crate::storage::sstable::version_gate::VersionGates::from_path(path) {
68            Ok(gates) => Some(gates),
69            Err(e @ Error::UnsupportedVersion { .. }) => return Err(e),
70            Err(e) => {
71                tracing::debug!(
72                    "StatisticsReader::open: could not derive VersionGates from {:?} ({}); \
73                     defaulting to nb-compatible behaviour",
74                    path,
75                    e
76                );
77                None
78            }
79        };
80
81        if !platform.fs().exists(path).await? {
82            return Err(Error::not_found(format!(
83                "Statistics.db file not found: {}",
84                path.display()
85            )));
86        }
87
88        // Read the entire file (Statistics.db files are typically small).
89        // Reached only for at-or-above-floor (or structurally-unparseable)
90        // descriptors — a below-floor version was already rejected above.
91        let mut file = File::open(path).await?;
92        let mut buffer = Vec::new();
93        file.read_to_end(&mut buffer).await?;
94
95        let statistics = match parse_statistics_with_fallback(&buffer, gates.as_ref()) {
96            Ok((_, stats)) => stats,
97            Err(e) => {
98                return Err(Error::corruption(format!(
99                    "Failed to parse Statistics.db with enhanced parser: {:?}",
100                    e
101                )));
102            }
103        };
104
105        // Checksum validation: DEFERRED TO M2 MILESTONE
106        //
107        // The nb-format Statistics.db checksum algorithm is not yet implemented (Issue #28).
108        // The checksum field (header.checksum) contains a value but we cannot validate it
109        // without knowing the exact algorithm Cassandra 5.0+ uses for this file format.
110        //
111        // Known limitations:
112        // - Files with corrupt data may be silently accepted
113        // - No data integrity guarantee for Statistics.db parsing
114        // - M2 milestone will implement proper CRC32/Adler32/other validation
115        //
116        // This limitation is explicitly documented rather than silently ignored.
117        // Statistics.db is metadata-only (not critical path) so risk is acceptable for M1.
118        if statistics.header.checksum != 0 {
119            // Checksum present but not validated - this is a known M1 limitation
120        }
121
122        Ok(Self {
123            file_path: path.to_path_buf(),
124            statistics,
125            platform,
126        })
127    }
128
129    /// Get the raw statistics data
130    pub fn statistics(&self) -> &SSTableStatistics {
131        &self.statistics
132    }
133
134    /// Get a human-readable summary analysis
135    pub fn analyze(&self) -> StatisticsSummary {
136        StatisticsAnalyzer::analyze(&self.statistics)
137    }
138
139    /// Get the file path
140    pub fn file_path(&self) -> &Path {
141        &self.file_path
142    }
143
144    /// Validate checksum for parsed statistics
145    pub async fn validate_checksum(&self) -> Result<bool> {
146        // Read the raw file data for checksum calculation
147        let mut file = File::open(&self.file_path).await?;
148        let mut buffer = Vec::new();
149        file.read_to_end(&mut buffer).await?;
150
151        if buffer.len() < 4 {
152            return Err(Error::corruption(
153                "Statistics file too small for checksum validation".to_string(),
154            ));
155        }
156
157        // Extract the stored checksum from header
158        let stored_checksum = self.statistics.header.checksum;
159
160        // Calculate CRC32 checksum of the data section (excluding the checksum field itself)
161        let data_section = if buffer.len() >= 32 {
162            &buffer[28..buffer.len() - 4] // Skip header to checksum field, then skip checksum
163        } else {
164            return Err(Error::corruption(
165                "Invalid Statistics file format for checksum validation".to_string(),
166            ));
167        };
168
169        let calculated_checksum = crc32_checksum(data_section);
170
171        Ok(calculated_checksum == stored_checksum)
172    }
173
174    /// Check if the Statistics.db corresponds to a specific table
175    pub fn matches_table(&self, table_id: &[u8; 16]) -> bool {
176        if let Some(ref stats_table_id) = self.statistics.header.table_id {
177            stats_table_id == table_id
178        } else {
179            false // Cannot match without table ID
180        }
181    }
182
183    /// Get row count information
184    pub fn row_count(&self) -> u64 {
185        self.statistics.row_stats.total_rows
186    }
187
188    /// Get live row count (excluding tombstones)
189    pub fn live_row_count(&self) -> u64 {
190        self.statistics.row_stats.live_rows
191    }
192
193    /// Get timestamp range in microseconds as `(min, max)`.
194    ///
195    /// Note (#1729/#1653): the returned `max` is the `i64::MIN` "unavailable"
196    /// sentinel when the enhanced (nb) parser could not authoritatively decode
197    /// `maxTimestamp` from STATS (`max_timestamp == None`). This legacy tuple
198    /// accessor preserves that documented sentinel contract; callers that need a
199    /// guaranteed-real maximum should use [`Self::max_timestamp`], which returns
200    /// `None` for that case.
201    pub fn timestamp_range(&self) -> (i64, i64) {
202        (
203            self.statistics.timestamp_stats.min_timestamp,
204            self.statistics
205                .timestamp_stats
206                .max_timestamp
207                .unwrap_or(i64::MIN),
208        )
209    }
210
211    /// Get the authoritative SSTable `maxTimestamp` (microseconds), or `None`
212    /// when it is not available (issue #1653 — `max_timestamp == None`).
213    ///
214    /// Consumers that must not proceed without a real maximum (e.g. a
215    /// compaction drop/GC gate, #1388) should gate on this returning `Some`.
216    pub fn max_timestamp(&self) -> Option<i64> {
217        // Defense-in-depth (#1653): both parsers should already map Cassandra's
218        // `Long.MIN_VALUE` "no max recorded" sentinel to `None`, but degrade a
219        // stray `Some(i64::MIN)` from ANY source to `None` here too, so a
220        // caller can never read the sentinel as a real maximum.
221        self.statistics
222            .timestamp_stats
223            .max_timestamp
224            .filter(|&ts| ts != crate::parser::repair_metadata::NO_MAX_TIMESTAMP_SENTINEL)
225    }
226
227    /// Get compression information as `(algorithm, ratio)`, or `None` when
228    /// compression statistics were not authoritatively parsed from
229    /// `Statistics.db` (issue #1653 — the enhanced nb parser does not decode
230    /// them; the value is not fabricated as `("unknown", 1.0)`).
231    pub fn compression_info(&self) -> Option<(&str, f64)> {
232        self.statistics
233            .compression_stats
234            .as_ref()
235            .map(|c| (c.algorithm.as_str(), c.ratio))
236    }
237
238    /// Get partition statistics as `(min_size, avg_size, max_size)`, or `None`
239    /// when partition statistics were not authoritatively parsed from
240    /// `Statistics.db` (issue #1653 — not fabricated as all-zero).
241    pub fn partition_info(&self) -> Option<(u64, f64, u64)> {
242        self.statistics.partition_stats.as_ref().map(|p| {
243            (
244                p.min_partition_size,
245                p.avg_partition_size,
246                p.max_partition_size,
247            )
248        })
249    }
250
251    /// Get column statistics by name
252    pub fn column_stats(
253        &self,
254        column_name: &str,
255    ) -> Option<&crate::parser::statistics::ColumnStatistics> {
256        self.statistics
257            .column_stats
258            .iter()
259            .find(|col| col.name == column_name)
260    }
261
262    /// Get all column names with statistics
263    pub fn column_names(&self) -> Vec<&str> {
264        self.statistics
265            .column_stats
266            .iter()
267            .map(|col| col.name.as_str())
268            .collect()
269    }
270
271    /// Check if data has TTL information.
272    ///
273    /// Returns `false` when the rows-with-TTL count is not authoritatively
274    /// available (issue #1653 — `rows_with_ttl == None`, the enhanced nb parser
275    /// case): "unknown" is reported as "no known TTL data" rather than
276    /// fabricating a positive count.
277    pub fn has_ttl_data(&self) -> bool {
278        matches!(self.statistics.timestamp_stats.rows_with_ttl, Some(n) if n > 0)
279    }
280
281    /// Get disk space usage as `(compressed, uncompressed, ratio)`, or `None`
282    /// when table statistics were not authoritatively parsed from
283    /// `Statistics.db` (issue #1653 — not fabricated as all-zero).
284    pub fn disk_usage(&self) -> Option<(u64, u64, f64)> {
285        self.statistics
286            .table_stats
287            .as_ref()
288            .map(|t| (t.compressed_size, t.uncompressed_size, t.compression_ratio))
289    }
290
291    /// Generate a detailed report
292    pub fn generate_report(&self, include_column_details: bool) -> String {
293        let mut report = String::new();
294        let summary = self.analyze();
295
296        report.push_str("# SSTable Statistics Report\n\n");
297
298        // Overview section
299        report.push_str("## Overview\n");
300        // `total_rows == 0` is the documented #1325 "not authoritatively
301        // reachable from STATS" sentinel (the version-gated walk could not reach
302        // `totalRows`), not a measured zero. Render it as `unavailable` rather
303        // than a misleading `0` (#1352). No heuristic (#28).
304        report.push_str(&match summary.total_rows {
305            0 => "- **Total Rows**: unavailable\n".to_string(),
306            n => format!("- **Total Rows**: {}\n", n),
307        });
308        // `live_data_percentage` is `None` when `live_rows` is the documented
309        // #1325 "not authoritatively available from STATS" sentinel; render it as
310        // unavailable rather than a misleading concrete 0.00% (#1352).
311        report.push_str(&match summary.live_data_percentage {
312            Some(pct) => format!("- **Live Data**: {:.2}%\n", pct),
313            None => "- **Live Data**: unavailable\n".to_string(),
314        });
315        // `None` = compression stats not authoritatively parsed (issue #1653);
316        // render "unavailable" rather than a fabricated 0.00%/100%.
317        report.push_str(&match summary.compression_efficiency {
318            Some(eff) => format!("- **Compression Efficiency**: {:.2}%\n", eff),
319            None => "- **Compression Efficiency**: unavailable\n".to_string(),
320        });
321        report.push_str(&format!(
322            "- **Time Range**: {:.1} days\n",
323            summary.timestamp_range_days
324        ));
325        // `None` = partition stats not authoritatively parsed (issue #1653).
326        report.push_str(&match summary.largest_partition_mb {
327            Some(mb) => format!("- **Largest Partition**: {:.2} MB\n", mb),
328            None => "- **Largest Partition**: unavailable\n".to_string(),
329        });
330        report.push_str(&format!(
331            "- **Health Score**: {:.1}/100\n\n",
332            summary.health_score
333        ));
334
335        // Row statistics
336        report.push_str("## Row Statistics\n");
337        // Same #1325 `total_rows == 0` unavailable sentinel as the overview above.
338        report.push_str(&match self.statistics.row_stats.total_rows {
339            0 => "- Total rows: unavailable\n".to_string(),
340            n => format!("- Total rows: {}\n", n),
341        });
342        // `live_rows == 0` is the documented #1325 "not authoritatively
343        // available from STATS" sentinel (STATS has no per-SSTable live-row
344        // count), not a measured zero. Render it as `unavailable` for
345        // consistency with the overview live-data% above rather than a
346        // misleading `0` (#1352).
347        report.push_str(&match self.statistics.row_stats.live_rows {
348            0 => "- Live rows: unavailable\n".to_string(),
349            live => format!("- Live rows: {}\n", live),
350        });
351        report.push_str(&format!(
352            "- Tombstones: {}\n",
353            self.statistics.row_stats.tombstone_count
354        ));
355        report.push_str(&format!(
356            "- Partitions: {}\n",
357            self.statistics.row_stats.partition_count
358        ));
359        // `avg_rows_per_partition == 0.0` is the documented #1325 unavailable
360        // sentinel: the parser leaves it 0.0 whenever `total_rows` is not
361        // authoritatively reachable OR `partition_count == 0` (the average is
362        // `total_rows / partition_count`, so BOTH must be positive for the value
363        // to be real). Render it as `unavailable` unless both counts are positive
364        // rather than a misleading `0.0` (#1352). Availability guard is
365        // single-sourced via `avg_rows_available`. No heuristic (#28).
366        report.push_str(
367            &if crate::parser::statistics::avg_rows_available(&self.statistics) {
368                format!(
369                    "- Average rows per partition: {:.1}\n\n",
370                    self.statistics.row_stats.avg_rows_per_partition
371                )
372            } else {
373                "- Average rows per partition: unavailable\n\n".to_string()
374            },
375        );
376
377        // Timestamp information
378        if self.statistics.timestamp_stats.min_timestamp != 0 {
379            let min_time = chrono::DateTime::from_timestamp_micros(
380                self.statistics.timestamp_stats.min_timestamp,
381            );
382            // #1729/#1653: `max_timestamp` is `None` when unavailable. Only build a
383            // datetime from an authoritative maximum — never from a sentinel.
384            let max_ts = self.max_timestamp();
385            let max_time = max_ts.and_then(chrono::DateTime::from_timestamp_micros);
386
387            report.push_str("## Timestamp Range\n");
388            // #1729/#1653: render the max honestly rather than printing a bogus
389            // epoch derived from an unavailable maximum.
390            if let (Some(min), Some(max)) = (min_time, max_time) {
391                report.push_str(&format!(
392                    "- From: {}\n",
393                    min.format("%Y-%m-%d %H:%M:%S UTC")
394                ));
395                report.push_str(&format!("- To: {}\n", max.format("%Y-%m-%d %H:%M:%S UTC")));
396            } else {
397                report.push_str(&format!(
398                    "- Min timestamp: {}\n",
399                    self.statistics.timestamp_stats.min_timestamp
400                ));
401                match max_ts {
402                    Some(max) => {
403                        report.push_str(&format!("- Max timestamp: {}\n", max));
404                    }
405                    None => {
406                        report.push_str("- Max timestamp: unavailable\n");
407                    }
408                }
409            }
410
411            // `has_ttl_data()` is only true when `rows_with_ttl` is an
412            // authoritative positive count (issue #1653), so this render never
413            // uses the `None`/unavailable case.
414            if let Some(rows_with_ttl) = self.statistics.timestamp_stats.rows_with_ttl {
415                if rows_with_ttl > 0 {
416                    report.push_str(&format!("- Rows with TTL: {}\n", rows_with_ttl));
417                }
418            }
419            report.push('\n');
420        }
421
422        // Compression statistics. `None` = not authoritatively parsed (issue
423        // #1653, the enhanced nb parser case); render "unavailable" rather than a
424        // fabricated `algorithm: unknown` + all-zero block.
425        report.push_str("## Compression\n");
426        match self.statistics.compression_stats.as_ref() {
427            Some(c) => {
428                report.push_str(&format!("- Algorithm: {}\n", c.algorithm));
429                report.push_str(&format!(
430                    "- Original size: {:.2} MB\n",
431                    c.original_size as f64 / 1_048_576.0
432                ));
433                report.push_str(&format!(
434                    "- Compressed size: {:.2} MB\n",
435                    c.compressed_size as f64 / 1_048_576.0
436                ));
437                report.push_str(&format!("- Ratio: {:.2}%\n", c.ratio * 100.0));
438                report.push_str(&format!(
439                    "- Speed: {:.1} MB/s (compress), {:.1} MB/s (decompress)\n\n",
440                    c.compression_speed, c.decompression_speed
441                ));
442            }
443            None => report.push_str("- unavailable\n\n"),
444        }
445
446        // Partition statistics. `None` = not authoritatively parsed (issue #1653).
447        report.push_str("## Partition Distribution\n");
448        match self.statistics.partition_stats.as_ref() {
449            Some(p) => {
450                report.push_str(&format!(
451                    "- Average size: {:.2} KB\n",
452                    p.avg_partition_size / 1024.0
453                ));
454                report.push_str(&format!(
455                    "- Range: {:.2} KB - {:.2} MB\n",
456                    p.min_partition_size as f64 / 1024.0,
457                    p.max_partition_size as f64 / 1_048_576.0
458                ));
459                report.push_str(&format!(
460                    "- Large partitions (>1MB): {:.1}%\n\n",
461                    p.large_partition_percentage
462                ));
463            }
464            None => report.push_str("- unavailable\n\n"),
465        }
466
467        // Column statistics
468        if include_column_details && !self.statistics.column_stats.is_empty() {
469            report.push_str("## Column Statistics\n");
470            for column in &self.statistics.column_stats {
471                report.push_str(&format!("### {}\n", column.name));
472                report.push_str(&format!("- Type: {}\n", column.column_type));
473                report.push_str(&format!("- Values: {}\n", column.value_count));
474                report.push_str(&format!("- Nulls: {}\n", column.null_count));
475                report.push_str(&format!("- Average size: {:.1} bytes\n", column.avg_size));
476                report.push_str(&format!("- Cardinality: {}\n", column.cardinality));
477                if column.has_index {
478                    report.push_str("- **Indexed**: Yes\n");
479                }
480                report.push('\n');
481            }
482        }
483
484        // Performance hints
485        if !summary.query_performance_hints.is_empty() {
486            report.push_str("## Query Performance Hints\n");
487            for hint in &summary.query_performance_hints {
488                report.push_str(&format!("- {}\n", hint));
489            }
490            report.push('\n');
491        }
492
493        // Storage recommendations
494        if !summary.storage_recommendations.is_empty() {
495            report.push_str("## Storage Recommendations\n");
496            for rec in &summary.storage_recommendations {
497                report.push_str(&format!("- {}\n", rec));
498            }
499            report.push('\n');
500        }
501
502        report
503    }
504
505    /// Get a compact summary for CLI display
506    pub fn compact_summary(&self) -> String {
507        let summary = self.analyze();
508        // See `generate_report`: `None` = live-data% not authoritatively available
509        // (#1325 sentinel), rendered as "?% live" instead of a misleading 0.0%.
510        let live = match summary.live_data_percentage {
511            Some(pct) => format!("{:.1}% live", pct),
512            None => "?% live".to_string(),
513        };
514        // `total_rows == 0` is the documented #1325 "not authoritatively reachable
515        // from STATS" sentinel; render it as "?" here rather than a misleading `0`
516        // (matches the "?% live" convention above) (#1352). No heuristic (#28).
517        let rows = match summary.total_rows {
518            0 => "?".to_string(),
519            n => n.to_string(),
520        };
521        // `None` = compression / table stats not authoritatively parsed (issue
522        // #1653); render "?" rather than a fabricated 0.0% / 0.00 MB.
523        let compression = match summary.compression_efficiency {
524            Some(eff) => format!("{:.1}%", eff),
525            None => "?".to_string(),
526        };
527        let size = match self.statistics.table_stats.as_ref() {
528            Some(t) => format!("{:.2} MB", t.disk_size as f64 / 1_048_576.0),
529            None => "? MB".to_string(),
530        };
531        format!(
532            "Rows: {} ({}) | Compression: {} | Health: {:.0}/100 | Size: {}",
533            rows, live, compression, summary.health_score, size
534        )
535    }
536
537    /// Build a reader directly from in-memory statistics (test-only).
538    ///
539    /// The production constructor requires a real on-disk `Statistics.db`; this
540    /// lets the report-rendering unit tests exercise the #1325 unavailable-sentinel
541    /// rendering without a fixture file.
542    #[cfg(test)]
543    async fn from_statistics_for_test(statistics: SSTableStatistics) -> Self {
544        let config = crate::Config::default();
545        let platform =
546            std::sync::Arc::new(crate::Platform::new(&config).await.expect("test platform"));
547        Self {
548            file_path: PathBuf::from("in-memory-test-Statistics.db"),
549            statistics,
550            platform,
551        }
552    }
553}
554
555/// Utility function to find Statistics.db file for a given Data.db file
556pub async fn find_statistics_file(data_db_path: &Path) -> Option<PathBuf> {
557    if let Some(parent) = data_db_path.parent() {
558        if let Some(stem) = data_db_path.file_stem() {
559            if let Some(stem_str) = stem.to_str() {
560                // Replace "Data.db" with "Statistics.db"
561                let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
562                let stats_path = parent.join(stats_name);
563
564                if tokio::fs::metadata(&stats_path).await.is_ok() {
565                    return Some(stats_path);
566                }
567            }
568        }
569    }
570    None
571}
572
573/// Utility function to check if a Statistics.db file exists for an SSTable directory
574pub async fn check_statistics_availability(sstable_dir: &Path) -> Result<Vec<PathBuf>> {
575    let mut stats_files = Vec::new();
576
577    let mut dir_entries = tokio::fs::read_dir(sstable_dir).await?;
578    while let Some(entry) = dir_entries.next_entry().await? {
579        let path = entry.path();
580        if let Some(file_name) = path.file_name() {
581            if let Some(name_str) = file_name.to_str() {
582                if name_str.contains("-Statistics.db") {
583                    stats_files.push(path);
584                }
585            }
586        }
587    }
588
589    Ok(stats_files)
590}
591
592#[cfg(test)]
593mod tests {
594
595    #[tokio::test]
596    async fn test_statistics_reader_creation() {
597        // This test would require a real Statistics.db file
598        // For now, just test the basic structure
599        assert!(true);
600    }
601
602    #[tokio::test]
603    async fn test_find_statistics_file() {
604        use std::path::PathBuf;
605
606        let data_path = PathBuf::from("/path/to/sstables/users-123abc-Data.db");
607        // find_statistics_file would look for users-123abc-Statistics.db
608        // This is a unit test for the path manipulation logic
609
610        if let Some(_parent) = data_path.parent() {
611            if let Some(stem) = data_path.file_stem() {
612                if let Some(stem_str) = stem.to_str() {
613                    let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
614                    assert_eq!(stats_name, "users-123abc-Statistics.db");
615                }
616            }
617        }
618    }
619
620    /// #1249 (roborev finding 1): a below-floor `*-Statistics.db` descriptor
621    /// MUST make `StatisticsReader::open` fail with a typed
622    /// `Error::UnsupportedVersion` rather than silently degrading to
623    /// nb-compatible defaults and proceeding to parse. Drives the public
624    /// `StatisticsReader::open` surface against a real on-disk file whose
625    /// version (`ma`, a pre-`na` BIG version) is below the floor.
626    #[tokio::test]
627    async fn test_open_below_floor_statistics_rejected() {
628        use crate::error::Error;
629        use std::sync::Arc;
630
631        let dir = tempfile::tempdir().expect("create tempdir");
632        // `ma` is a pre-`na` BIG version, below the version floor (#1249).
633        let path = dir.path().join("ma-1-big-Statistics.db");
634        // A present-but-below-floor file must still be rejected with the typed
635        // floor error rather than parsed; this exercises the gate-floor branch
636        // for an on-disk file.
637        std::fs::write(&path, b"not really a valid statistics file").expect("write fixture");
638
639        let config = crate::Config::default();
640        let platform = Arc::new(
641            crate::Platform::new(&config)
642                .await
643                .expect("create platform"),
644        );
645
646        let result = super::StatisticsReader::open(&path, platform).await;
647
648        match result {
649            Err(Error::UnsupportedVersion { version, floor }) => {
650                assert_eq!(version, "ma", "error must name the offending version");
651                assert_eq!(floor, "na", "error must name the na BIG floor");
652            }
653            Err(other) => panic!(
654                "expected UnsupportedVersion for below-floor Statistics.db, got {:?}",
655                other
656            ),
657            Ok(_) => panic!("below-floor Statistics.db must NOT open on nb-compatible defaults"),
658        }
659    }
660
661    /// #1297: an unknown ABOVE-floor BIG version (`nc`, outside the exact
662    /// `{na, nb, oa}` allowlist) must make `StatisticsReader::open` fail with a
663    /// typed `Error::UnsupportedVersion` rather than parse an unvalidated layout
664    /// on nb-compatible gates. Drives the public `StatisticsReader::open`
665    /// surface (wiring evidence for the ceiling, not just the gate helper).
666    #[tokio::test]
667    async fn test_open_above_floor_unknown_statistics_rejected() {
668        use crate::error::Error;
669        use std::sync::Arc;
670
671        let dir = tempfile::tempdir().expect("create tempdir");
672        // `nc` is above the `na` floor but NOT in the supported allowlist.
673        let path = dir.path().join("nc-1-big-Statistics.db");
674        std::fs::write(&path, b"not really a valid statistics file").expect("write fixture");
675
676        let config = crate::Config::default();
677        let platform = Arc::new(
678            crate::Platform::new(&config)
679                .await
680                .expect("create platform"),
681        );
682
683        match super::StatisticsReader::open(&path, platform).await {
684            Err(Error::UnsupportedVersion { version, .. }) => {
685                assert_eq!(version, "nc", "error must name the offending version");
686            }
687            Err(other) => panic!(
688                "expected UnsupportedVersion for above-allowlist Statistics.db, got {:?}",
689                other
690            ),
691            Ok(_) => {
692                panic!("above-allowlist Statistics.db must NOT open on nb-compatible defaults")
693            }
694        }
695    }
696
697    /// #1249 R1 (roborev finding 1): the version-floor check fires BEFORE the
698    /// file body is read/parsed. A below-floor `*-Statistics.db` with an empty
699    /// (0-byte) body must STILL fail with `UnsupportedVersion` rather than a
700    /// parse/corruption error — proving `read_to_end` + the enhanced parser are
701    /// never reached for a below-floor descriptor.
702    #[tokio::test]
703    async fn test_open_below_floor_statistics_rejected_before_body_read() {
704        use crate::error::Error;
705        use std::sync::Arc;
706
707        let dir = tempfile::tempdir().expect("create tempdir");
708        let path = dir.path().join("ma-1-big-Statistics.db");
709        // Empty body: if the gate ran AFTER read_to_end/parse, an empty file
710        // would surface as a parse/corruption error, not UnsupportedVersion.
711        std::fs::write(&path, b"").expect("write empty fixture");
712
713        let config = crate::Config::default();
714        let platform = Arc::new(
715            crate::Platform::new(&config)
716                .await
717                .expect("create platform"),
718        );
719
720        match super::StatisticsReader::open(&path, platform).await {
721            Err(Error::UnsupportedVersion { version, floor }) => {
722                assert_eq!(version, "ma");
723                assert_eq!(floor, "na");
724            }
725            Err(other) => panic!(
726                "expected UnsupportedVersion before body read for empty below-floor \
727                 Statistics.db, got {:?}",
728                other
729            ),
730            Ok(_) => {
731                panic!("below-floor Statistics.db with empty body must be rejected before parse")
732            }
733        }
734    }
735
736    /// #1249 R1: the version-floor check fires BEFORE *any* filesystem access.
737    /// A below-floor `*-Statistics.db` descriptor whose path does NOT exist on
738    /// disk must STILL fail with the typed `Error::UnsupportedVersion`, NOT a
739    /// `NotFound`/I/O error. This proves the floor is enforced from the filename
740    /// alone, before the existence check, `File::open`, and `read_to_end`
741    /// (mirrors `SSTableReader::open_inner` gating before `tokio::fs::metadata`).
742    #[tokio::test]
743    async fn test_open_below_floor_statistics_rejected_before_fs_access() {
744        use crate::error::Error;
745        use std::sync::Arc;
746
747        let dir = tempfile::tempdir().expect("create tempdir");
748        // `ma` is below the `na` BIG floor. Deliberately DO NOT create the file:
749        // if the existence check ran first, this would surface as NotFound.
750        let path = dir.path().join("ma-1-big-Statistics.db");
751        assert!(
752            !path.exists(),
753            "fixture path must not exist on disk for this test"
754        );
755
756        let config = crate::Config::default();
757        let platform = Arc::new(
758            crate::Platform::new(&config)
759                .await
760                .expect("create platform"),
761        );
762
763        match super::StatisticsReader::open(&path, platform).await {
764            Err(Error::UnsupportedVersion { version, floor }) => {
765                assert_eq!(version, "ma", "error must name the offending version");
766                assert_eq!(floor, "na", "error must name the na BIG floor");
767            }
768            Err(other) => panic!(
769                "expected UnsupportedVersion before any FS access for a nonexistent \
770                 below-floor Statistics.db, got {:?}",
771                other
772            ),
773            Ok(_) => panic!("below-floor Statistics.db must NOT open even when absent"),
774        }
775    }
776
777    /// #1729 end-to-end: opening a REAL nb `Statistics.db` must decode the
778    /// authoritative `maxTimestamp` from STATS field 4 (via the enhanced-parser
779    /// post-pass), NOT leave the old `max == min` placeholder or the `i64::MIN`
780    /// "unavailable" sentinel. Skips when the dataset binaries are absent.
781    #[tokio::test]
782    async fn test_open_real_nb_decodes_authoritative_max_timestamp() {
783        use std::path::PathBuf;
784        use std::sync::Arc;
785
786        let Ok(root) = std::env::var("CQLITE_DATASETS_ROOT") else {
787            eprintln!("CQLITE_DATASETS_ROOT not set, skipping");
788            return;
789        };
790        let path = PathBuf::from(root).join(
791            "sstables/test_timeseries/sensor_data-6c698230a25111f0a3fef1a551383fb9/\
792             nb-1-big-Statistics.db",
793        );
794        if !path.exists() {
795            eprintln!("dataset Statistics.db absent, skipping: {path:?}");
796            return;
797        }
798
799        let config = crate::Config::default();
800        let platform = Arc::new(
801            crate::Platform::new(&config)
802                .await
803                .expect("create platform"),
804        );
805        let reader = super::StatisticsReader::open(&path, platform)
806            .await
807            .expect("open real nb Statistics.db");
808
809        let (min, max) = reader.timestamp_range();
810        // A real time-series SSTable records write timestamps, so the
811        // authoritative max must be available (fail-closed None only when
812        // truly absent) and must be a real max >= min — never the i64::MIN
813        // sentinel.
814        let decoded_max = reader.max_timestamp();
815        assert!(
816            decoded_max.is_some(),
817            "a real time-series nb SSTable must expose an authoritative maxTimestamp"
818        );
819        let decoded_max = decoded_max.unwrap_or(i64::MIN);
820        assert_ne!(
821            decoded_max,
822            i64::MIN,
823            "max_timestamp must not be the unavailable sentinel for real data"
824        );
825        assert!(
826            decoded_max >= min,
827            "authoritative max ({decoded_max}) must be >= min ({min})"
828        );
829        assert_eq!(
830            max, decoded_max,
831            "timestamp_range max must match the authoritative decode"
832        );
833    }
834
835    /// Minimal in-memory statistics for report-rendering tests. `total_rows`,
836    /// `live_rows`, and `avg_rows_per_partition` are the #1325 sentinel-carrying
837    /// fields the caller sets per-scenario; everything else is a benign default.
838    #[cfg(test)]
839    fn stats_with(
840        total_rows: u64,
841        live_rows: u64,
842        avg_rows_per_partition: f64,
843    ) -> crate::parser::statistics::SSTableStatistics {
844        use crate::parser::statistics::*;
845        SSTableStatistics {
846            header: StatisticsHeader {
847                version: 4,
848                statistics_kind: 0,
849                data_length: 0,
850                metadata1: 0,
851                metadata2: 0,
852                metadata3: 0,
853                checksum: 0,
854                table_id: None,
855            },
856            row_stats: RowStatistics {
857                total_rows,
858                live_rows,
859                tombstone_count: 0,
860                partition_count: 10,
861                avg_rows_per_partition,
862                row_size_histogram: vec![],
863            },
864            timestamp_stats: TimestampStatistics {
865                min_timestamp: 0,
866                max_timestamp: Some(0),
867                min_deletion_time: 0,
868                max_deletion_time: 0,
869                min_ttl: None,
870                max_ttl: None,
871                rows_with_ttl: Some(0),
872            },
873            column_stats: vec![],
874            table_stats: Some(TableStatistics {
875                disk_size: 1024,
876                uncompressed_size: 1024,
877                compressed_size: 1024,
878                compression_ratio: 1.0,
879                block_count: 1,
880                avg_block_size: 1024.0,
881                index_size: 0,
882                bloom_filter_size: 0,
883                level_count: 0,
884            }),
885            partition_stats: Some(PartitionStatistics {
886                avg_partition_size: 0.0,
887                min_partition_size: 0,
888                max_partition_size: 0,
889                size_histogram: vec![],
890                large_partition_percentage: 0.0,
891            }),
892            compression_stats: Some(CompressionStatistics {
893                algorithm: "none".to_string(),
894                original_size: 1024,
895                compressed_size: 1024,
896                ratio: 1.0,
897                compression_speed: 0.0,
898                decompression_speed: 0.0,
899                compressed_blocks: 0,
900            }),
901            metadata: std::collections::HashMap::new(),
902            serialization_header_columns: vec![],
903            serialization_header_partition_keys: vec![],
904            serialization_header_clustering_keys: vec![],
905            tombstone_drop_times: vec![],
906        }
907    }
908
909    /// #1325 sweep: `generate_report` and `compact_summary` must render the
910    /// unavailable sentinels (`total_rows == 0`, `avg_rows_per_partition == 0.0`)
911    /// as "unavailable"/"?" rather than a misleading concrete `0`.
912    #[tokio::test]
913    async fn test_report_renders_unavailable_when_counts_sentinel() {
914        let reader = super::StatisticsReader::from_statistics_for_test(stats_with(0, 0, 0.0)).await;
915
916        let report = reader.generate_report(false);
917        assert!(
918            report.contains("**Total Rows**: unavailable"),
919            "overview Total Rows must render unavailable for the total_rows==0 sentinel:\n{report}"
920        );
921        assert!(
922            report.contains("Total rows: unavailable"),
923            "row-stats Total rows must render unavailable for the sentinel:\n{report}"
924        );
925        assert!(
926            report.contains("Average rows per partition: unavailable"),
927            "avg rows/partition must render unavailable for the sentinel:\n{report}"
928        );
929        assert!(
930            !report.contains("Total rows: 0"),
931            "must NOT render the misleading concrete 0:\n{report}"
932        );
933
934        let compact = reader.compact_summary();
935        assert!(
936            compact.contains("Rows: ?"),
937            "compact summary must render '?' for the total_rows==0 sentinel: {compact}"
938        );
939    }
940
941    /// #1325 sweep (non-vacuous positive path): real counts render as concrete
942    /// numbers, not "unavailable".
943    #[tokio::test]
944    async fn test_report_renders_concrete_when_counts_real() {
945        let reader =
946            super::StatisticsReader::from_statistics_for_test(stats_with(1000, 900, 20.0)).await;
947
948        let report = reader.generate_report(false);
949        assert!(
950            report.contains("**Total Rows**: 1000"),
951            "overview must render the real total_rows:\n{report}"
952        );
953        assert!(
954            report.contains("Average rows per partition: 20.0"),
955            "avg rows/partition must render the real value:\n{report}"
956        );
957        assert!(
958            !report.contains("Total Rows**: unavailable"),
959            "must NOT render unavailable when the count is real:\n{report}"
960        );
961
962        let compact = reader.compact_summary();
963        assert!(
964            compact.contains("Rows: 1000"),
965            "compact summary must render the real total_rows: {compact}"
966        );
967    }
968
969    /// Build `stats_with` and override the timestamp range (#1729).
970    #[cfg(test)]
971    fn stats_with_timestamps(
972        min_timestamp: i64,
973        max_timestamp: i64,
974    ) -> crate::parser::statistics::SSTableStatistics {
975        let mut s = stats_with(1, 1, 1.0);
976        s.timestamp_stats.min_timestamp = min_timestamp;
977        s.timestamp_stats.max_timestamp = Some(max_timestamp);
978        s
979    }
980
981    /// #1729: when the authoritative maxTimestamp is present it must be exposed
982    /// as `Some(max)` — and it is the TRUE max (min < X < max), not the min.
983    #[tokio::test]
984    async fn test_max_timestamp_authoritative_is_some_and_true_max() {
985        let min = 1_000_000i64;
986        let max = 5_000_000i64;
987        let reader =
988            super::StatisticsReader::from_statistics_for_test(stats_with_timestamps(min, max))
989                .await;
990        assert_eq!(reader.max_timestamp(), Some(max));
991        assert_ne!(
992            reader.max_timestamp(),
993            Some(min),
994            "max_timestamp must be the true max, never the min placeholder"
995        );
996        // The range accessor still returns both endpoints.
997        assert_eq!(reader.timestamp_range(), (min, max));
998    }
999
1000    /// #1729/#1653 fail-closed: an unavailable authoritative maxTimestamp
1001    /// (`max_timestamp == None`) surfaces as `None` from the accessor, and the
1002    /// report renders "unavailable" rather than a bogus epoch or leaked sentinel.
1003    #[tokio::test]
1004    async fn test_max_timestamp_unavailable_is_none_and_report_unavailable() {
1005        let min = 1_000_000i64;
1006        // The unavailable case is now honestly `None` (issue #1653), not the old
1007        // `i64::MIN` in-band sentinel.
1008        let mut stats = stats_with_timestamps(min, 0);
1009        stats.timestamp_stats.max_timestamp = None;
1010        let reader = super::StatisticsReader::from_statistics_for_test(stats).await;
1011        assert_eq!(
1012            reader.max_timestamp(),
1013            None,
1014            "an unavailable max_timestamp must surface as None (fail-closed)"
1015        );
1016
1017        let report = reader.generate_report(false);
1018        assert!(
1019            report.contains("Max timestamp: unavailable"),
1020            "report must render an unavailable max honestly, not a bogus epoch:\n{report}"
1021        );
1022        assert!(
1023            !report.contains("-9223372036854775808"),
1024            "report must NOT leak a raw i64::MIN value:\n{report}"
1025        );
1026    }
1027
1028    /// #1374 roborev finding: `avg_rows_per_partition` is
1029    /// `total_rows / partition_count`, so its `0.0` unavailable sentinel also
1030    /// occurs when `total_rows > 0` but `partition_count == 0`. The report must
1031    /// render "unavailable" in that case (guard requires BOTH counts positive).
1032    /// Total rows itself is real here, so it must still render concretely.
1033    #[tokio::test]
1034    async fn test_report_renders_avg_unavailable_when_partition_count_zero() {
1035        let mut stats = stats_with(1000, 900, 0.0);
1036        stats.row_stats.partition_count = 0; // avg is the unavailable sentinel
1037        let reader = super::StatisticsReader::from_statistics_for_test(stats).await;
1038
1039        let report = reader.generate_report(false);
1040        assert!(
1041            report.contains("Average rows per partition: unavailable"),
1042            "avg rows/partition must render unavailable when partition_count == 0:\n{report}"
1043        );
1044        assert!(
1045            !report.contains("Average rows per partition: 0.0"),
1046            "must NOT render the misleading concrete 0.0 avg:\n{report}"
1047        );
1048        // Total rows is authoritative here, so it must still render concretely.
1049        assert!(
1050            report.contains("Total rows: 1000"),
1051            "row-stats Total rows must still render the real count:\n{report}"
1052        );
1053    }
1054}