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                log::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): the returned `max` may be the `i64::MIN` "unavailable"
196    /// sentinel when the enhanced (nb) parser could not authoritatively decode
197    /// `maxTimestamp` from STATS. Callers that need a guaranteed-real maximum
198    /// should use [`Self::max_timestamp`], which returns `None` for that case.
199    pub fn timestamp_range(&self) -> (i64, i64) {
200        (
201            self.statistics.timestamp_stats.min_timestamp,
202            self.statistics.timestamp_stats.max_timestamp,
203        )
204    }
205
206    /// Get the authoritative SSTable `maxTimestamp` (microseconds), or `None`
207    /// when it is not available (#1729 fail-closed sentinel `i64::MIN`).
208    ///
209    /// Consumers that must not proceed without a real maximum (e.g. a
210    /// compaction drop/GC gate, #1388) should gate on this returning `Some`.
211    pub fn max_timestamp(&self) -> Option<i64> {
212        match self.statistics.timestamp_stats.max_timestamp {
213            i64::MIN => None,
214            v => Some(v),
215        }
216    }
217
218    /// Get compression information
219    pub fn compression_info(&self) -> (&str, f64) {
220        (
221            &self.statistics.compression_stats.algorithm,
222            self.statistics.compression_stats.ratio,
223        )
224    }
225
226    /// Get partition statistics
227    pub fn partition_info(&self) -> (u64, f64, u64) {
228        (
229            self.statistics.partition_stats.min_partition_size,
230            self.statistics.partition_stats.avg_partition_size,
231            self.statistics.partition_stats.max_partition_size,
232        )
233    }
234
235    /// Get column statistics by name
236    pub fn column_stats(
237        &self,
238        column_name: &str,
239    ) -> Option<&crate::parser::statistics::ColumnStatistics> {
240        self.statistics
241            .column_stats
242            .iter()
243            .find(|col| col.name == column_name)
244    }
245
246    /// Get all column names with statistics
247    pub fn column_names(&self) -> Vec<&str> {
248        self.statistics
249            .column_stats
250            .iter()
251            .map(|col| col.name.as_str())
252            .collect()
253    }
254
255    /// Check if data has TTL information
256    pub fn has_ttl_data(&self) -> bool {
257        self.statistics.timestamp_stats.rows_with_ttl > 0
258    }
259
260    /// Get disk space usage information
261    pub fn disk_usage(&self) -> (u64, u64, f64) {
262        (
263            self.statistics.table_stats.compressed_size,
264            self.statistics.table_stats.uncompressed_size,
265            self.statistics.table_stats.compression_ratio,
266        )
267    }
268
269    /// Generate a detailed report
270    pub fn generate_report(&self, include_column_details: bool) -> String {
271        let mut report = String::new();
272        let summary = self.analyze();
273
274        report.push_str("# SSTable Statistics Report\n\n");
275
276        // Overview section
277        report.push_str("## Overview\n");
278        // `total_rows == 0` is the documented #1325 "not authoritatively
279        // reachable from STATS" sentinel (the version-gated walk could not reach
280        // `totalRows`), not a measured zero. Render it as `unavailable` rather
281        // than a misleading `0` (#1352). No heuristic (#28).
282        report.push_str(&match summary.total_rows {
283            0 => "- **Total Rows**: unavailable\n".to_string(),
284            n => format!("- **Total Rows**: {}\n", n),
285        });
286        // `live_data_percentage` is `None` when `live_rows` is the documented
287        // #1325 "not authoritatively available from STATS" sentinel; render it as
288        // unavailable rather than a misleading concrete 0.00% (#1352).
289        report.push_str(&match summary.live_data_percentage {
290            Some(pct) => format!("- **Live Data**: {:.2}%\n", pct),
291            None => "- **Live Data**: unavailable\n".to_string(),
292        });
293        report.push_str(&format!(
294            "- **Compression Efficiency**: {:.2}%\n",
295            summary.compression_efficiency
296        ));
297        report.push_str(&format!(
298            "- **Time Range**: {:.1} days\n",
299            summary.timestamp_range_days
300        ));
301        report.push_str(&format!(
302            "- **Largest Partition**: {:.2} MB\n",
303            summary.largest_partition_mb
304        ));
305        report.push_str(&format!(
306            "- **Health Score**: {:.1}/100\n\n",
307            summary.health_score
308        ));
309
310        // Row statistics
311        report.push_str("## Row Statistics\n");
312        // Same #1325 `total_rows == 0` unavailable sentinel as the overview above.
313        report.push_str(&match self.statistics.row_stats.total_rows {
314            0 => "- Total rows: unavailable\n".to_string(),
315            n => format!("- Total rows: {}\n", n),
316        });
317        // `live_rows == 0` is the documented #1325 "not authoritatively
318        // available from STATS" sentinel (STATS has no per-SSTable live-row
319        // count), not a measured zero. Render it as `unavailable` for
320        // consistency with the overview live-data% above rather than a
321        // misleading `0` (#1352).
322        report.push_str(&match self.statistics.row_stats.live_rows {
323            0 => "- Live rows: unavailable\n".to_string(),
324            live => format!("- Live rows: {}\n", live),
325        });
326        report.push_str(&format!(
327            "- Tombstones: {}\n",
328            self.statistics.row_stats.tombstone_count
329        ));
330        report.push_str(&format!(
331            "- Partitions: {}\n",
332            self.statistics.row_stats.partition_count
333        ));
334        // `avg_rows_per_partition == 0.0` is the documented #1325 unavailable
335        // sentinel: the parser leaves it 0.0 whenever `total_rows` is not
336        // authoritatively reachable OR `partition_count == 0` (the average is
337        // `total_rows / partition_count`, so BOTH must be positive for the value
338        // to be real). Render it as `unavailable` unless both counts are positive
339        // rather than a misleading `0.0` (#1352). Availability guard is
340        // single-sourced via `avg_rows_available`. No heuristic (#28).
341        report.push_str(
342            &if crate::parser::statistics::avg_rows_available(&self.statistics) {
343                format!(
344                    "- Average rows per partition: {:.1}\n\n",
345                    self.statistics.row_stats.avg_rows_per_partition
346                )
347            } else {
348                "- Average rows per partition: unavailable\n\n".to_string()
349            },
350        );
351
352        // Timestamp information
353        if self.statistics.timestamp_stats.min_timestamp != 0 {
354            let min_time = chrono::DateTime::from_timestamp_micros(
355                self.statistics.timestamp_stats.min_timestamp,
356            );
357            let max_time = chrono::DateTime::from_timestamp_micros(
358                self.statistics.timestamp_stats.max_timestamp,
359            );
360
361            report.push_str("## Timestamp Range\n");
362            // #1729: `max_timestamp()` returns None for the `i64::MIN`
363            // "unavailable" sentinel — render it honestly rather than printing a
364            // bogus epoch derived from i64::MIN.
365            let max_available = self.max_timestamp().is_some();
366            if let (Some(min), Some(max)) = (min_time, max_time) {
367                report.push_str(&format!(
368                    "- From: {}\n",
369                    min.format("%Y-%m-%d %H:%M:%S UTC")
370                ));
371                report.push_str(&format!("- To: {}\n", max.format("%Y-%m-%d %H:%M:%S UTC")));
372            } else {
373                report.push_str(&format!(
374                    "- Min timestamp: {}\n",
375                    self.statistics.timestamp_stats.min_timestamp
376                ));
377                if max_available {
378                    report.push_str(&format!(
379                        "- Max timestamp: {}\n",
380                        self.statistics.timestamp_stats.max_timestamp
381                    ));
382                } else {
383                    report.push_str("- Max timestamp: unavailable\n");
384                }
385            }
386
387            if self.has_ttl_data() {
388                report.push_str(&format!(
389                    "- Rows with TTL: {}\n",
390                    self.statistics.timestamp_stats.rows_with_ttl
391                ));
392            }
393            report.push('\n');
394        }
395
396        // Compression statistics
397        report.push_str("## Compression\n");
398        report.push_str(&format!(
399            "- Algorithm: {}\n",
400            self.statistics.compression_stats.algorithm
401        ));
402        report.push_str(&format!(
403            "- Original size: {:.2} MB\n",
404            self.statistics.compression_stats.original_size as f64 / 1_048_576.0
405        ));
406        report.push_str(&format!(
407            "- Compressed size: {:.2} MB\n",
408            self.statistics.compression_stats.compressed_size as f64 / 1_048_576.0
409        ));
410        report.push_str(&format!(
411            "- Ratio: {:.2}%\n",
412            self.statistics.compression_stats.ratio * 100.0
413        ));
414        report.push_str(&format!(
415            "- Speed: {:.1} MB/s (compress), {:.1} MB/s (decompress)\n\n",
416            self.statistics.compression_stats.compression_speed,
417            self.statistics.compression_stats.decompression_speed
418        ));
419
420        // Partition statistics
421        report.push_str("## Partition Distribution\n");
422        report.push_str(&format!(
423            "- Average size: {:.2} KB\n",
424            self.statistics.partition_stats.avg_partition_size / 1024.0
425        ));
426        report.push_str(&format!(
427            "- Range: {:.2} KB - {:.2} MB\n",
428            self.statistics.partition_stats.min_partition_size as f64 / 1024.0,
429            self.statistics.partition_stats.max_partition_size as f64 / 1_048_576.0
430        ));
431        report.push_str(&format!(
432            "- Large partitions (>1MB): {:.1}%\n\n",
433            self.statistics.partition_stats.large_partition_percentage
434        ));
435
436        // Column statistics
437        if include_column_details && !self.statistics.column_stats.is_empty() {
438            report.push_str("## Column Statistics\n");
439            for column in &self.statistics.column_stats {
440                report.push_str(&format!("### {}\n", column.name));
441                report.push_str(&format!("- Type: {}\n", column.column_type));
442                report.push_str(&format!("- Values: {}\n", column.value_count));
443                report.push_str(&format!("- Nulls: {}\n", column.null_count));
444                report.push_str(&format!("- Average size: {:.1} bytes\n", column.avg_size));
445                report.push_str(&format!("- Cardinality: {}\n", column.cardinality));
446                if column.has_index {
447                    report.push_str("- **Indexed**: Yes\n");
448                }
449                report.push('\n');
450            }
451        }
452
453        // Performance hints
454        if !summary.query_performance_hints.is_empty() {
455            report.push_str("## Query Performance Hints\n");
456            for hint in &summary.query_performance_hints {
457                report.push_str(&format!("- {}\n", hint));
458            }
459            report.push('\n');
460        }
461
462        // Storage recommendations
463        if !summary.storage_recommendations.is_empty() {
464            report.push_str("## Storage Recommendations\n");
465            for rec in &summary.storage_recommendations {
466                report.push_str(&format!("- {}\n", rec));
467            }
468            report.push('\n');
469        }
470
471        report
472    }
473
474    /// Get a compact summary for CLI display
475    pub fn compact_summary(&self) -> String {
476        let summary = self.analyze();
477        // See `generate_report`: `None` = live-data% not authoritatively available
478        // (#1325 sentinel), rendered as "?% live" instead of a misleading 0.0%.
479        let live = match summary.live_data_percentage {
480            Some(pct) => format!("{:.1}% live", pct),
481            None => "?% live".to_string(),
482        };
483        // `total_rows == 0` is the documented #1325 "not authoritatively reachable
484        // from STATS" sentinel; render it as "?" here rather than a misleading `0`
485        // (matches the "?% live" convention above) (#1352). No heuristic (#28).
486        let rows = match summary.total_rows {
487            0 => "?".to_string(),
488            n => n.to_string(),
489        };
490        format!(
491            "Rows: {} ({}) | Compression: {:.1}% | Health: {:.0}/100 | Size: {:.2} MB",
492            rows,
493            live,
494            summary.compression_efficiency,
495            summary.health_score,
496            self.statistics.table_stats.disk_size as f64 / 1_048_576.0
497        )
498    }
499
500    /// Build a reader directly from in-memory statistics (test-only).
501    ///
502    /// The production constructor requires a real on-disk `Statistics.db`; this
503    /// lets the report-rendering unit tests exercise the #1325 unavailable-sentinel
504    /// rendering without a fixture file.
505    #[cfg(test)]
506    async fn from_statistics_for_test(statistics: SSTableStatistics) -> Self {
507        let config = crate::Config::default();
508        let platform =
509            std::sync::Arc::new(crate::Platform::new(&config).await.expect("test platform"));
510        Self {
511            file_path: PathBuf::from("in-memory-test-Statistics.db"),
512            statistics,
513            platform,
514        }
515    }
516}
517
518/// Utility function to find Statistics.db file for a given Data.db file
519pub async fn find_statistics_file(data_db_path: &Path) -> Option<PathBuf> {
520    if let Some(parent) = data_db_path.parent() {
521        if let Some(stem) = data_db_path.file_stem() {
522            if let Some(stem_str) = stem.to_str() {
523                // Replace "Data.db" with "Statistics.db"
524                let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
525                let stats_path = parent.join(stats_name);
526
527                if tokio::fs::metadata(&stats_path).await.is_ok() {
528                    return Some(stats_path);
529                }
530            }
531        }
532    }
533    None
534}
535
536/// Utility function to check if a Statistics.db file exists for an SSTable directory
537pub async fn check_statistics_availability(sstable_dir: &Path) -> Result<Vec<PathBuf>> {
538    let mut stats_files = Vec::new();
539
540    let mut dir_entries = tokio::fs::read_dir(sstable_dir).await?;
541    while let Some(entry) = dir_entries.next_entry().await? {
542        let path = entry.path();
543        if let Some(file_name) = path.file_name() {
544            if let Some(name_str) = file_name.to_str() {
545                if name_str.contains("-Statistics.db") {
546                    stats_files.push(path);
547                }
548            }
549        }
550    }
551
552    Ok(stats_files)
553}
554
555#[cfg(test)]
556mod tests {
557
558    #[tokio::test]
559    async fn test_statistics_reader_creation() {
560        // This test would require a real Statistics.db file
561        // For now, just test the basic structure
562        assert!(true);
563    }
564
565    #[tokio::test]
566    async fn test_find_statistics_file() {
567        use std::path::PathBuf;
568
569        let data_path = PathBuf::from("/path/to/sstables/users-123abc-Data.db");
570        // find_statistics_file would look for users-123abc-Statistics.db
571        // This is a unit test for the path manipulation logic
572
573        if let Some(_parent) = data_path.parent() {
574            if let Some(stem) = data_path.file_stem() {
575                if let Some(stem_str) = stem.to_str() {
576                    let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
577                    assert_eq!(stats_name, "users-123abc-Statistics.db");
578                }
579            }
580        }
581    }
582
583    /// #1249 (roborev finding 1): a below-floor `*-Statistics.db` descriptor
584    /// MUST make `StatisticsReader::open` fail with a typed
585    /// `Error::UnsupportedVersion` rather than silently degrading to
586    /// nb-compatible defaults and proceeding to parse. Drives the public
587    /// `StatisticsReader::open` surface against a real on-disk file whose
588    /// version (`ma`, a pre-`na` BIG version) is below the floor.
589    #[tokio::test]
590    async fn test_open_below_floor_statistics_rejected() {
591        use crate::error::Error;
592        use std::sync::Arc;
593
594        let dir = tempfile::tempdir().expect("create tempdir");
595        // `ma` is a pre-`na` BIG version, below the version floor (#1249).
596        let path = dir.path().join("ma-1-big-Statistics.db");
597        // A present-but-below-floor file must still be rejected with the typed
598        // floor error rather than parsed; this exercises the gate-floor branch
599        // for an on-disk file.
600        std::fs::write(&path, b"not really a valid statistics file").expect("write fixture");
601
602        let config = crate::Config::default();
603        let platform = Arc::new(
604            crate::Platform::new(&config)
605                .await
606                .expect("create platform"),
607        );
608
609        let result = super::StatisticsReader::open(&path, platform).await;
610
611        match result {
612            Err(Error::UnsupportedVersion { version, floor }) => {
613                assert_eq!(version, "ma", "error must name the offending version");
614                assert_eq!(floor, "na", "error must name the na BIG floor");
615            }
616            Err(other) => panic!(
617                "expected UnsupportedVersion for below-floor Statistics.db, got {:?}",
618                other
619            ),
620            Ok(_) => panic!("below-floor Statistics.db must NOT open on nb-compatible defaults"),
621        }
622    }
623
624    /// #1297: an unknown ABOVE-floor BIG version (`nc`, outside the exact
625    /// `{na, nb, oa}` allowlist) must make `StatisticsReader::open` fail with a
626    /// typed `Error::UnsupportedVersion` rather than parse an unvalidated layout
627    /// on nb-compatible gates. Drives the public `StatisticsReader::open`
628    /// surface (wiring evidence for the ceiling, not just the gate helper).
629    #[tokio::test]
630    async fn test_open_above_floor_unknown_statistics_rejected() {
631        use crate::error::Error;
632        use std::sync::Arc;
633
634        let dir = tempfile::tempdir().expect("create tempdir");
635        // `nc` is above the `na` floor but NOT in the supported allowlist.
636        let path = dir.path().join("nc-1-big-Statistics.db");
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        match super::StatisticsReader::open(&path, platform).await {
647            Err(Error::UnsupportedVersion { version, .. }) => {
648                assert_eq!(version, "nc", "error must name the offending version");
649            }
650            Err(other) => panic!(
651                "expected UnsupportedVersion for above-allowlist Statistics.db, got {:?}",
652                other
653            ),
654            Ok(_) => {
655                panic!("above-allowlist Statistics.db must NOT open on nb-compatible defaults")
656            }
657        }
658    }
659
660    /// #1249 R1 (roborev finding 1): the version-floor check fires BEFORE the
661    /// file body is read/parsed. A below-floor `*-Statistics.db` with an empty
662    /// (0-byte) body must STILL fail with `UnsupportedVersion` rather than a
663    /// parse/corruption error — proving `read_to_end` + the enhanced parser are
664    /// never reached for a below-floor descriptor.
665    #[tokio::test]
666    async fn test_open_below_floor_statistics_rejected_before_body_read() {
667        use crate::error::Error;
668        use std::sync::Arc;
669
670        let dir = tempfile::tempdir().expect("create tempdir");
671        let path = dir.path().join("ma-1-big-Statistics.db");
672        // Empty body: if the gate ran AFTER read_to_end/parse, an empty file
673        // would surface as a parse/corruption error, not UnsupportedVersion.
674        std::fs::write(&path, b"").expect("write empty 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, floor }) => {
685                assert_eq!(version, "ma");
686                assert_eq!(floor, "na");
687            }
688            Err(other) => panic!(
689                "expected UnsupportedVersion before body read for empty below-floor \
690                 Statistics.db, got {:?}",
691                other
692            ),
693            Ok(_) => {
694                panic!("below-floor Statistics.db with empty body must be rejected before parse")
695            }
696        }
697    }
698
699    /// #1249 R1: the version-floor check fires BEFORE *any* filesystem access.
700    /// A below-floor `*-Statistics.db` descriptor whose path does NOT exist on
701    /// disk must STILL fail with the typed `Error::UnsupportedVersion`, NOT a
702    /// `NotFound`/I/O error. This proves the floor is enforced from the filename
703    /// alone, before the existence check, `File::open`, and `read_to_end`
704    /// (mirrors `SSTableReader::open_inner` gating before `tokio::fs::metadata`).
705    #[tokio::test]
706    async fn test_open_below_floor_statistics_rejected_before_fs_access() {
707        use crate::error::Error;
708        use std::sync::Arc;
709
710        let dir = tempfile::tempdir().expect("create tempdir");
711        // `ma` is below the `na` BIG floor. Deliberately DO NOT create the file:
712        // if the existence check ran first, this would surface as NotFound.
713        let path = dir.path().join("ma-1-big-Statistics.db");
714        assert!(
715            !path.exists(),
716            "fixture path must not exist on disk for this test"
717        );
718
719        let config = crate::Config::default();
720        let platform = Arc::new(
721            crate::Platform::new(&config)
722                .await
723                .expect("create platform"),
724        );
725
726        match super::StatisticsReader::open(&path, platform).await {
727            Err(Error::UnsupportedVersion { version, floor }) => {
728                assert_eq!(version, "ma", "error must name the offending version");
729                assert_eq!(floor, "na", "error must name the na BIG floor");
730            }
731            Err(other) => panic!(
732                "expected UnsupportedVersion before any FS access for a nonexistent \
733                 below-floor Statistics.db, got {:?}",
734                other
735            ),
736            Ok(_) => panic!("below-floor Statistics.db must NOT open even when absent"),
737        }
738    }
739
740    /// #1729 end-to-end: opening a REAL nb `Statistics.db` must decode the
741    /// authoritative `maxTimestamp` from STATS field 4 (via the enhanced-parser
742    /// post-pass), NOT leave the old `max == min` placeholder or the `i64::MIN`
743    /// "unavailable" sentinel. Skips when the dataset binaries are absent.
744    #[tokio::test]
745    async fn test_open_real_nb_decodes_authoritative_max_timestamp() {
746        use std::path::PathBuf;
747        use std::sync::Arc;
748
749        let Ok(root) = std::env::var("CQLITE_DATASETS_ROOT") else {
750            eprintln!("CQLITE_DATASETS_ROOT not set, skipping");
751            return;
752        };
753        let path = PathBuf::from(root).join(
754            "sstables/test_timeseries/sensor_data-6c698230a25111f0a3fef1a551383fb9/\
755             nb-1-big-Statistics.db",
756        );
757        if !path.exists() {
758            eprintln!("dataset Statistics.db absent, skipping: {path:?}");
759            return;
760        }
761
762        let config = crate::Config::default();
763        let platform = Arc::new(
764            crate::Platform::new(&config)
765                .await
766                .expect("create platform"),
767        );
768        let reader = super::StatisticsReader::open(&path, platform)
769            .await
770            .expect("open real nb Statistics.db");
771
772        let (min, max) = reader.timestamp_range();
773        // A real time-series SSTable records write timestamps, so the
774        // authoritative max must be available (fail-closed None only when
775        // truly absent) and must be a real max >= min — never the i64::MIN
776        // sentinel.
777        let decoded_max = reader.max_timestamp();
778        assert!(
779            decoded_max.is_some(),
780            "a real time-series nb SSTable must expose an authoritative maxTimestamp"
781        );
782        let decoded_max = decoded_max.unwrap_or(i64::MIN);
783        assert_ne!(
784            decoded_max,
785            i64::MIN,
786            "max_timestamp must not be the unavailable sentinel for real data"
787        );
788        assert!(
789            decoded_max >= min,
790            "authoritative max ({decoded_max}) must be >= min ({min})"
791        );
792        assert_eq!(
793            max, decoded_max,
794            "timestamp_range max must match the authoritative decode"
795        );
796    }
797
798    /// Minimal in-memory statistics for report-rendering tests. `total_rows`,
799    /// `live_rows`, and `avg_rows_per_partition` are the #1325 sentinel-carrying
800    /// fields the caller sets per-scenario; everything else is a benign default.
801    #[cfg(test)]
802    fn stats_with(
803        total_rows: u64,
804        live_rows: u64,
805        avg_rows_per_partition: f64,
806    ) -> crate::parser::statistics::SSTableStatistics {
807        use crate::parser::statistics::*;
808        SSTableStatistics {
809            header: StatisticsHeader {
810                version: 4,
811                statistics_kind: 0,
812                data_length: 0,
813                metadata1: 0,
814                metadata2: 0,
815                metadata3: 0,
816                checksum: 0,
817                table_id: None,
818            },
819            row_stats: RowStatistics {
820                total_rows,
821                live_rows,
822                tombstone_count: 0,
823                partition_count: 10,
824                avg_rows_per_partition,
825                row_size_histogram: vec![],
826            },
827            timestamp_stats: TimestampStatistics {
828                min_timestamp: 0,
829                max_timestamp: 0,
830                min_deletion_time: 0,
831                max_deletion_time: 0,
832                min_ttl: None,
833                max_ttl: None,
834                rows_with_ttl: 0,
835            },
836            column_stats: vec![],
837            table_stats: TableStatistics {
838                disk_size: 1024,
839                uncompressed_size: 1024,
840                compressed_size: 1024,
841                compression_ratio: 1.0,
842                block_count: 1,
843                avg_block_size: 1024.0,
844                index_size: 0,
845                bloom_filter_size: 0,
846                level_count: 0,
847            },
848            partition_stats: PartitionStatistics {
849                avg_partition_size: 0.0,
850                min_partition_size: 0,
851                max_partition_size: 0,
852                size_histogram: vec![],
853                large_partition_percentage: 0.0,
854            },
855            compression_stats: CompressionStatistics {
856                algorithm: "none".to_string(),
857                original_size: 1024,
858                compressed_size: 1024,
859                ratio: 1.0,
860                compression_speed: 0.0,
861                decompression_speed: 0.0,
862                compressed_blocks: 0,
863            },
864            metadata: std::collections::HashMap::new(),
865            serialization_header_columns: vec![],
866            serialization_header_partition_keys: vec![],
867            serialization_header_clustering_keys: vec![],
868            tombstone_drop_times: vec![],
869        }
870    }
871
872    /// #1325 sweep: `generate_report` and `compact_summary` must render the
873    /// unavailable sentinels (`total_rows == 0`, `avg_rows_per_partition == 0.0`)
874    /// as "unavailable"/"?" rather than a misleading concrete `0`.
875    #[tokio::test]
876    async fn test_report_renders_unavailable_when_counts_sentinel() {
877        let reader = super::StatisticsReader::from_statistics_for_test(stats_with(0, 0, 0.0)).await;
878
879        let report = reader.generate_report(false);
880        assert!(
881            report.contains("**Total Rows**: unavailable"),
882            "overview Total Rows must render unavailable for the total_rows==0 sentinel:\n{report}"
883        );
884        assert!(
885            report.contains("Total rows: unavailable"),
886            "row-stats Total rows must render unavailable for the sentinel:\n{report}"
887        );
888        assert!(
889            report.contains("Average rows per partition: unavailable"),
890            "avg rows/partition must render unavailable for the sentinel:\n{report}"
891        );
892        assert!(
893            !report.contains("Total rows: 0"),
894            "must NOT render the misleading concrete 0:\n{report}"
895        );
896
897        let compact = reader.compact_summary();
898        assert!(
899            compact.contains("Rows: ?"),
900            "compact summary must render '?' for the total_rows==0 sentinel: {compact}"
901        );
902    }
903
904    /// #1325 sweep (non-vacuous positive path): real counts render as concrete
905    /// numbers, not "unavailable".
906    #[tokio::test]
907    async fn test_report_renders_concrete_when_counts_real() {
908        let reader =
909            super::StatisticsReader::from_statistics_for_test(stats_with(1000, 900, 20.0)).await;
910
911        let report = reader.generate_report(false);
912        assert!(
913            report.contains("**Total Rows**: 1000"),
914            "overview must render the real total_rows:\n{report}"
915        );
916        assert!(
917            report.contains("Average rows per partition: 20.0"),
918            "avg rows/partition must render the real value:\n{report}"
919        );
920        assert!(
921            !report.contains("Total Rows**: unavailable"),
922            "must NOT render unavailable when the count is real:\n{report}"
923        );
924
925        let compact = reader.compact_summary();
926        assert!(
927            compact.contains("Rows: 1000"),
928            "compact summary must render the real total_rows: {compact}"
929        );
930    }
931
932    /// Build `stats_with` and override the timestamp range (#1729).
933    #[cfg(test)]
934    fn stats_with_timestamps(
935        min_timestamp: i64,
936        max_timestamp: i64,
937    ) -> crate::parser::statistics::SSTableStatistics {
938        let mut s = stats_with(1, 1, 1.0);
939        s.timestamp_stats.min_timestamp = min_timestamp;
940        s.timestamp_stats.max_timestamp = max_timestamp;
941        s
942    }
943
944    /// #1729: when the authoritative maxTimestamp is present it must be exposed
945    /// as `Some(max)` — and it is the TRUE max (min < X < max), not the min.
946    #[tokio::test]
947    async fn test_max_timestamp_authoritative_is_some_and_true_max() {
948        let min = 1_000_000i64;
949        let max = 5_000_000i64;
950        let reader =
951            super::StatisticsReader::from_statistics_for_test(stats_with_timestamps(min, max))
952                .await;
953        assert_eq!(reader.max_timestamp(), Some(max));
954        assert_ne!(
955            reader.max_timestamp(),
956            Some(min),
957            "max_timestamp must be the true max, never the min placeholder"
958        );
959        // The range accessor still returns both endpoints.
960        assert_eq!(reader.timestamp_range(), (min, max));
961    }
962
963    /// #1729 fail-closed: the `i64::MIN` sentinel (Cassandra's "no max recorded"
964    /// / enhanced-parser "unavailable") surfaces as `None`, and the report
965    /// renders "unavailable" rather than a bogus epoch derived from i64::MIN.
966    #[tokio::test]
967    async fn test_max_timestamp_sentinel_is_none_and_report_unavailable() {
968        let min = 1_000_000i64;
969        let reader =
970            super::StatisticsReader::from_statistics_for_test(stats_with_timestamps(min, i64::MIN))
971                .await;
972        assert_eq!(
973            reader.max_timestamp(),
974            None,
975            "the i64::MIN unavailable sentinel must surface as None (fail-closed)"
976        );
977
978        let report = reader.generate_report(false);
979        assert!(
980            report.contains("Max timestamp: unavailable"),
981            "report must render an unavailable max honestly, not a bogus epoch:\n{report}"
982        );
983        assert!(
984            !report.contains("-9223372036854775808"),
985            "report must NOT leak the raw i64::MIN sentinel:\n{report}"
986        );
987    }
988
989    /// #1374 roborev finding: `avg_rows_per_partition` is
990    /// `total_rows / partition_count`, so its `0.0` unavailable sentinel also
991    /// occurs when `total_rows > 0` but `partition_count == 0`. The report must
992    /// render "unavailable" in that case (guard requires BOTH counts positive).
993    /// Total rows itself is real here, so it must still render concretely.
994    #[tokio::test]
995    async fn test_report_renders_avg_unavailable_when_partition_count_zero() {
996        let mut stats = stats_with(1000, 900, 0.0);
997        stats.row_stats.partition_count = 0; // avg is the unavailable sentinel
998        let reader = super::StatisticsReader::from_statistics_for_test(stats).await;
999
1000        let report = reader.generate_report(false);
1001        assert!(
1002            report.contains("Average rows per partition: unavailable"),
1003            "avg rows/partition must render unavailable when partition_count == 0:\n{report}"
1004        );
1005        assert!(
1006            !report.contains("Average rows per partition: 0.0"),
1007            "must NOT render the misleading concrete 0.0 avg:\n{report}"
1008        );
1009        // Total rows is authoritative here, so it must still render concretely.
1010        assert!(
1011            report.contains("Total rows: 1000"),
1012            "row-stats Total rows must still render the real count:\n{report}"
1013        );
1014    }
1015}