Skip to main content

cqlite_core/storage/sstable/reader/
mod.rs

1//! SSTable reader implementation
2//!
3//! This module provides efficient reading of SSTable files in Cassandra 5+ format.
4//! It supports:
5//! - Block-based reading with compression
6//! - Index-based lookups for efficient queries
7//! - Memory-efficient streaming
8//! - Bloom filter integration
9//! - Multiple compression algorithms
10
11// Submodules
12mod block_io;
13mod cache;
14/// Per-element / per-cell compaction read contract (epic #899, Phase A).
15pub mod compaction_row;
16mod component_loading;
17mod compression;
18/// `CRC.db` reader for uncompressed BIG read-time integrity (issue #1396).
19pub(crate) mod crc;
20mod data_access;
21/// Delta-scan record model (Epic #696, Issue #697).
22/// Only compiled when the `delta-scan` feature is enabled.
23#[cfg(feature = "delta-scan")]
24pub mod delta_scan;
25mod header;
26mod header_helpers;
27mod integrity;
28mod key_digest;
29pub(crate) mod parsing; // Needs to be accessible from row_cell_state_machine
30mod partition_lookup;
31// Positional (`pread`-style) point-read backends (issue #1573, Epic C / C2).
32mod read_at;
33// Concurrency scenarios for the ReadAt point-read migration (issue #1573).
34#[cfg(test)]
35mod read_at_point_tests;
36// sync-fallback registry-schema pre-resolution (issue #1692)
37#[cfg(feature = "state_machine")]
38mod registry_schema;
39// Windowed streaming-scan driver (issue #1143); `pub` ONLY under non-default
40// `scan-offload-probe` so the #1143 guard reaches its probe, else private.
41#[cfg(not(feature = "scan-offload-probe"))]
42mod scan_stream_windowed;
43#[cfg(feature = "scan-offload-probe")]
44pub mod scan_stream_windowed;
45mod source;
46#[cfg(test)]
47mod tests;
48mod types;
49// Sliding-window byte cursor + its test-only byte-movement probe (issue #1589);
50// `pub` ONLY under the non-default `scan-offload-probe` feature so the guard test
51// reaches `window_cursor::probe`, else crate-private.
52#[cfg(not(feature = "scan-offload-probe"))]
53pub(crate) mod window_cursor;
54#[cfg(feature = "scan-offload-probe")]
55pub mod window_cursor;
56
57// Re-export public types
58pub use types::{
59    BlockMeta, CachedBlock, IntegrityCheckResult, IntegrityStatus, SSTableReader,
60    SSTableReaderConfig, SSTableReaderHealthMetrics, SSTableReaderStats,
61};
62// Re-export the within-partition clustering-slice push-down spec (Issue #954).
63pub use data_access::ClusteringSlice;
64// Re-export the per-element compaction read contract (epic #899, Phase A).
65pub use compaction_row::{
66    CompactionRow, CompactionRowData, ComplexColumn, ComplexElement, SimpleCell,
67};
68// Re-export V5CompressedLegacyParser for integration testing (Issue #166 regression tests)
69#[doc(hidden)]
70pub use parsing::PublicV5CompressedLegacyParser as V5CompressedLegacyParser;
71
72// Re-export compression utilities for testing (Issue #202)
73#[doc(hidden)]
74pub use compression::extract_sstable_base_name;
75
76// Internal imports from submodules
77use compression::detect_and_initialize_compression;
78use header::{
79    calculate_actual_header_size, extract_generation_from_path, parse_header_with_version_detection,
80};
81
82use rustc_hash::FxHashMap;
83use std::path::Path;
84use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
85use std::sync::Arc;
86use tokio::fs::File;
87use tokio::io::{AsyncReadExt, AsyncSeekExt};
88use tokio::sync::Mutex;
89
90use source::{BlockSource, ScanSource};
91
92use crate::{
93    config::{DiskAccessMode, PrefetchMode},
94    parser::{header::CassandraVersion, SSTableHeader, SSTableParser},
95    platform::Platform,
96    schema::TableSchema,
97    storage::sstable::{
98        compression_info::CompressionInfo,
99        version_gate::{BigVersionGates, VersionGates},
100    },
101    Config, Error, Result, RowKey, ScanRow, Value,
102};
103
104// Structured logging
105use log::debug;
106
107#[cfg(feature = "tombstones")]
108use super::tombstone_merger::TombstoneMerger;
109
110/// Returns `true` when memory-mapped reads are force-enabled via the
111/// `CQLITE_USE_MMAP` environment variable.
112///
113/// Accepts `1`, `true`, `yes`, `on` (case-insensitive). Any other value — or
114/// an unset variable — leaves the decision to [`Config`]. This is an opt-in
115/// escape hatch for ad-hoc local use without threading a custom config.
116fn mmap_enabled_via_env() -> bool {
117    std::env::var("CQLITE_USE_MMAP")
118        .ok()
119        .as_deref()
120        .map(parse_truthy_env)
121        .unwrap_or(false)
122}
123
124/// Parse a truthy environment-variable value (`1`/`true`/`yes`/`on`,
125/// case-insensitive). Split out so it can be unit-tested without mutating the
126/// process-global environment (which would race other `open()` tests).
127fn parse_truthy_env(value: &str) -> bool {
128    matches!(
129        value.trim().to_ascii_lowercase().as_str(),
130        "1" | "true" | "yes" | "on"
131    )
132}
133
134/// Parse a [`DiskAccessMode`] from a string (`auto`/`buffered`/`mmap`/`direct`,
135/// case-insensitive). Returns `None` for unrecognized values so callers can
136/// keep the configured default. Pure for unit-testing without env mutation.
137fn parse_disk_access_mode(value: &str) -> Option<DiskAccessMode> {
138    match value.trim().to_ascii_lowercase().as_str() {
139        "auto" => Some(DiskAccessMode::Auto),
140        "buffered" | "buffer" => Some(DiskAccessMode::Buffered),
141        "mmap" | "mapped" => Some(DiskAccessMode::Mmap),
142        "direct" | "directio" | "direct_io" | "o_direct" => Some(DiskAccessMode::Direct),
143        _ => None,
144    }
145}
146
147/// Parse a [`PrefetchMode`] from a string (`off`/`sequential`/`willneed`/`auto`).
148/// Returns `None` for unrecognized values. Pure for unit-testing.
149fn parse_prefetch_mode(value: &str) -> Option<PrefetchMode> {
150    match value.trim().to_ascii_lowercase().as_str() {
151        "off" | "none" | "no" => Some(PrefetchMode::Off),
152        "sequential" | "seq" => Some(PrefetchMode::Sequential),
153        "willneed" | "will_need" | "will-need" => Some(PrefetchMode::WillNeed),
154        "auto" => Some(PrefetchMode::Auto),
155        _ => None,
156    }
157}
158
159/// `CQLITE_DISK_ACCESS_MODE` override, if set to a recognized value.
160fn disk_access_mode_via_env() -> Option<DiskAccessMode> {
161    std::env::var("CQLITE_DISK_ACCESS_MODE")
162        .ok()
163        .as_deref()
164        .and_then(parse_disk_access_mode)
165}
166
167/// `CQLITE_PREFETCH` override, if set to a recognized value.
168fn prefetch_mode_via_env() -> Option<PrefetchMode> {
169    std::env::var("CQLITE_PREFETCH")
170        .ok()
171        .as_deref()
172        .and_then(parse_prefetch_mode)
173}
174
175/// Best-effort total physical RAM in bytes, or `None` when it cannot be
176/// determined on this platform. Used by [`DiskAccessMode::Auto`] to decide when
177/// a file is large enough to warrant page-cache-bypassing direct I/O.
178fn system_memory_bytes() -> Option<u64> {
179    #[cfg(unix)]
180    {
181        // SAFETY: `sysconf` is a pure query with no pointer arguments.
182        let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
183        let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
184        if pages > 0 && page_size > 0 {
185            return Some((pages as u64).saturating_mul(page_size as u64));
186        }
187        None
188    }
189    #[cfg(not(unix))]
190    {
191        None
192    }
193}
194
195/// Decide which disk-access backend to use for a Data.db file.
196///
197/// Pure function (system memory injected) so the [`DiskAccessMode::Auto`]
198/// heuristic can be unit-tested deterministically. Resolution rules:
199/// - explicit `Buffered`/`Mmap`/`Direct` are returned unchanged (the caller
200///   applies graceful fallback if the OS refuses the backend);
201/// - `Auto` returns `Buffered` for files below `mmap_min_size_bytes`, `Direct`
202///   when the file exceeds `memory_fraction` of `system_memory` (and memory is
203///   known and direct I/O is available on this platform), otherwise `Mmap`.
204///
205/// The deprecated `use_mmap` flag / `CQLITE_USE_MMAP` env is folded in by the
206/// caller (it promotes a `Buffered` request to `Mmap`), so it is not an input
207/// here; an explicit mode always takes precedence.
208fn resolve_disk_access_mode(
209    configured: DiskAccessMode,
210    file_size: u64,
211    mmap_min_size_bytes: u64,
212    memory_fraction: f64,
213    system_memory: Option<u64>,
214    direct_io_available: bool,
215) -> DiskAccessMode {
216    // Zero-length files cannot be mapped and have nothing to read directly;
217    // always use buffered I/O for them regardless of the requested mode.
218    if file_size == 0 {
219        return DiskAccessMode::Buffered;
220    }
221    match configured {
222        DiskAccessMode::Buffered => DiskAccessMode::Buffered,
223        DiskAccessMode::Mmap => DiskAccessMode::Mmap,
224        DiskAccessMode::Direct => DiskAccessMode::Direct,
225        DiskAccessMode::Auto => {
226            if file_size < mmap_min_size_bytes {
227                return DiskAccessMode::Buffered;
228            }
229            let fraction = if memory_fraction.is_finite() && memory_fraction > 0.0 {
230                memory_fraction.min(1.0)
231            } else {
232                0.5
233            };
234            if direct_io_available {
235                if let Some(mem) = system_memory {
236                    let threshold = (mem as f64 * fraction) as u64;
237                    if file_size > threshold {
238                        return DiskAccessMode::Direct;
239                    }
240                }
241            }
242            DiskAccessMode::Mmap
243        }
244    }
245}
246
247/// Whether the direct-I/O backend is compiled in for this platform.
248const fn direct_io_available() -> bool {
249    cfg!(all(
250        unix,
251        any(
252            target_os = "linux",
253            target_os = "android",
254            target_os = "macos"
255        )
256    ))
257}
258
259/// Resolve a [`PrefetchMode`] into the concrete advice the mmap backend should
260/// issue, or `None` for "no advice".
261///
262/// `memmap2::Advice` / `Mmap::advise` (madvise) are Unix-only, so this and its
263/// single call site are gated to `#[cfg(unix)]`. On non-Unix targets the mmap
264/// backend simply issues no read-ahead advice.
265///
266/// [`PrefetchMode::Auto`] deliberately issues **no** madvise (issue #1143).
267/// `MADV_SEQUENTIAL` couples aggressive read-ahead with *drop-behind*: pages are
268/// evicted from the page cache as soon as the scan moves past them. In isolation
269/// that is fine (mmap scans are ~40% faster than buffered), but under concurrent
270/// write load the page-cache pressure means the just-dropped pages are gone by
271/// the time an overlapping scan needs them again, so re-reads take *synchronous*
272/// major page faults on the tokio worker thread and the read-side p99 tail blows
273/// up (~2x regression). Relying on the kernel's default read-ahead (no
274/// drop-behind) keeps the isolated mmap win while letting the page cache retain
275/// hot pages, which collapses that tail. Callers who genuinely want the
276/// drop-behind behaviour can still request `Sequential` explicitly
277/// (`CQLITE_PREFETCH=sequential` / [`StorageConfig::prefetch`]).
278#[cfg(unix)]
279fn mmap_advice_for(prefetch: PrefetchMode) -> Option<memmap2::Advice> {
280    match prefetch {
281        // No madvise: rely on the kernel's default read-ahead. Chosen for `Auto`
282        // to avoid `MADV_SEQUENTIAL` drop-behind evicting hot pages under
283        // concurrent write load (issue #1143).
284        PrefetchMode::Off | PrefetchMode::Auto => None,
285        // Explicit opt-in to aggressive read-ahead + drop-behind. Best for a
286        // one-shot full scan that will not be re-read and should not pin the
287        // whole file in the page cache.
288        PrefetchMode::Sequential => Some(memmap2::Advice::Sequential),
289        PrefetchMode::WillNeed => Some(memmap2::Advice::WillNeed),
290    }
291}
292
293/// Process-wide count of currently-open [`SSTableReader`]s, the source value
294/// for the [`SSTABLES_OPEN`](crate::observability::catalog::SSTABLES_OPEN)
295/// gauge. Tracked PER FORMAT so the gauge — which carries the
296/// [`cqlite.sstable.format`](crate::observability::catalog::attr::SSTABLE_FORMAT)
297/// attribute — reports the correct live count for each `big`/`bti` label series
298/// instead of a single global total stamped onto every series. Incremented when
299/// [`SSTableReader::open`] succeeds and decremented when a reader is dropped, so
300/// each gauge series reflects live readers regardless of the `observability`
301/// feature state (the helper calls are no-ops when off).
302static SSTABLES_OPEN_COUNT_BIG: AtomicI64 = AtomicI64::new(0);
303static SSTABLES_OPEN_COUNT_BTI: AtomicI64 = AtomicI64::new(0);
304
305/// Select the per-format open-reader counter for a `sstable_format_label()`
306/// value (`"big"` / `"bti"`). Defaults to the BIG counter for any unexpected
307/// label so the value is never silently dropped; the label set is bounded to
308/// the two [`sstable_format_label`](SSTableReader::sstable_format_label) returns.
309fn sstables_open_count_for(format: &str) -> &'static AtomicI64 {
310    match format {
311        "bti" => &SSTABLES_OPEN_COUNT_BTI,
312        _ => &SSTABLES_OPEN_COUNT_BIG,
313    }
314}
315
316impl SSTableReader {
317    /// Open an SSTable file for reading.
318    ///
319    /// Instrumented (epic #1031 / #1034): wraps the open in a
320    /// `sstable.reader.open` span, increments the [`SSTABLES_OPEN`] gauge on
321    /// success, and records an error on the `reader` subsystem when open fails.
322    ///
323    /// [`SSTABLES_OPEN`]: crate::observability::catalog::SSTABLES_OPEN
324    pub async fn open(path: &Path, config: &Config, platform: Arc<Platform>) -> Result<Self> {
325        // Back-compat: existing callers and sibling crates get a FRESH per-reader
326        // decompressed-chunk cache sized from config (issue #1567). Production
327        // reads route through `SSTableManager`, which calls `open_with_cache` with
328        // its SHARED instance so all readers of a dataset share one cache.
329        let cache = Arc::new(
330            crate::storage::cache::DecompressedChunkCache::with_budget_bytes(
331                config.memory.block_cache.max_size as usize,
332            ),
333        );
334        Self::open_with_cache(path, config, platform, cache).await
335    }
336
337    /// Open an SSTable file for reading, sharing the provided
338    /// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache).
339    ///
340    /// Identical to [`open`](Self::open) except the reader stores `cache` (an
341    /// `Arc` clone) instead of minting its own, so every reader a manager opens
342    /// for one dataset consults the same bytes-bounded chunk cache (issue #1567).
343    pub async fn open_with_cache(
344        path: &Path,
345        config: &Config,
346        platform: Arc<Platform>,
347        cache: Arc<crate::storage::cache::DecompressedChunkCache>,
348    ) -> Result<Self> {
349        use crate::observability::{self as obs, catalog};
350        use tracing::Instrument as _;
351
352        let span = tracing::debug_span!(
353            "sstable.reader.open",
354            file_size = tracing::field::Empty,
355            sstable_format = tracing::field::Empty,
356        );
357
358        // Instrument the future rather than holding an entered guard across the
359        // `.await`: entering a span guard and then awaiting can attach unrelated
360        // async work scheduled on this task to the span. `Instrument` enters the
361        // span only while this specific future is polled.
362        let result = Self::open_inner(path, config, platform, cache)
363            .instrument(span.clone())
364            .await;
365        match &result {
366            Ok(reader) => {
367                let format = reader.sstable_format_label();
368                span.record("file_size", reader.stats.file_size);
369                span.record("sstable_format", format);
370                // SSTABLES_OPEN is a snapshot gauge of the live reader count;
371                // record the current PER-FORMAT count after this open succeeds so
372                // the format-attributed gauge series stays correct under mixed
373                // BIG/BTI readers.
374                let now = sstables_open_count_for(format).fetch_add(1, Ordering::Relaxed) + 1;
375                obs::record_gauge(
376                    catalog::SSTABLES_OPEN,
377                    now,
378                    &[(catalog::attr::SSTABLE_FORMAT, format.into())],
379                );
380            }
381            Err(e) => {
382                // Record the error WHILE the open span is current so
383                // `mark_span_error` marks THIS `sstable.reader.open` span. The
384                // instrumented future has already completed here, so the span is
385                // no longer entered; `in_scope` re-enters it for the duration of
386                // the error-recording call.
387                span.in_scope(|| obs::record_error(e, "reader"));
388            }
389        }
390        result
391    }
392
393    /// Open implementation; see [`open`](Self::open) for the instrumented wrapper.
394    async fn open_inner(
395        path: &Path,
396        config: &Config,
397        platform: Arc<Platform>,
398        chunk_cache: Arc<crate::storage::cache::DecompressedChunkCache>,
399    ) -> Result<Self> {
400        // Retain the open-time Config before any local `config` shadowing so
401        // `perform_integrity_check` can delegate to `verify::verify_sstable`
402        // (single source of truth, issue #1283) under the same config.
403        let open_config = config.clone();
404        // #1249 (spec R1): reject below-floor versions BEFORE any file I/O.
405        // Gates derive solely from the filename, so this is the earliest point
406        // that can enforce the na+ floor — a pre-`na` (BIG) or non-`da` (BTI)
407        // descriptor is rejected with a typed `UnsupportedVersion` before we
408        // open/mmap/read the body (it never surfaces an I/O/parse error). Only a
409        // structurally-unparseable descriptor falls back to nb-compatible BIG
410        // gates (preserving existing tolerance for odd-but-5.0 unit-test paths);
411        // the gates do not change parsing decisions until VG3 flips behaviour.
412        let version_gates = Arc::new(match VersionGates::from_path(path) {
413            Ok(gates) => gates,
414            // A parsed-but-below-floor version is FATAL — never degrade it.
415            Err(e @ Error::UnsupportedVersion { .. }) => return Err(e),
416            Err(e) => {
417                log::debug!(
418                    "SSTableReader::open: could not derive VersionGates from {:?} ({}); \
419                     defaulting to nb-compatible BIG gates",
420                    path,
421                    e
422                );
423                VersionGates::Big(BigVersionGates::nb_fallback())
424            }
425        });
426
427        // Resolve the disk-access backend (buffered / mmap / direct, or auto)
428        // from config + environment overrides. See `Config::storage.disk_access_mode`.
429        let mut reader_config = SSTableReaderConfig::default();
430        reader_config.use_mmap = config.storage.use_mmap || mmap_enabled_via_env();
431        reader_config.mmap_min_size_bytes = config.storage.mmap_min_size_bytes;
432
433        let file_size = tokio::fs::metadata(path).await?.len();
434
435        // The explicit mode comes from env > config. The legacy `use_mmap` flag
436        // is folded in by promoting an otherwise-`Buffered` request to `Mmap` so
437        // the old opt-in keeps working (`Auto` and explicit non-buffered modes
438        // are left untouched, so a real backend decision always wins).
439        let configured_mode = disk_access_mode_via_env().unwrap_or(config.storage.disk_access_mode);
440        let configured_mode =
441            if reader_config.use_mmap && matches!(configured_mode, DiskAccessMode::Buffered) {
442                DiskAccessMode::Mmap
443            } else {
444                configured_mode
445            };
446
447        let resolved_mode = resolve_disk_access_mode(
448            configured_mode,
449            file_size,
450            reader_config.mmap_min_size_bytes as u64,
451            config.storage.direct_io_memory_fraction,
452            system_memory_bytes(),
453            direct_io_available(),
454        );
455
456        // Build both the shared point-read source and the per-scan factory from
457        // the same backend decision so concurrent scans get independent cursors
458        // (issue #815). `ScanSource::Mapped` shares the same `Arc<Mmap>` so no
459        // extra mapping is created per scan. Every non-buffered backend degrades
460        // gracefully to buffered I/O if the OS/filesystem refuses it.
461        let prefetch = prefetch_mode_via_env().unwrap_or(config.storage.prefetch);
462        let (source, scan_source) = Self::build_block_sources(
463            path,
464            file_size,
465            resolved_mode,
466            prefetch,
467            config.storage.direct_io_prefetch_bytes,
468        )
469        .await?;
470        let file = Arc::new(Mutex::new(source));
471
472        // Build the POINT-READ positional source ONCE at open (issue #1573, C2).
473        // It shares the reader's `Arc<Mmap>` when the backend is mmap (no extra
474        // mapping / fd); for buffered/direct it holds one dedicated read-only fd,
475        // which is exactly the "open the fd once, positioned-read thereafter"
476        // contract that removes the BTI per-lookup `open(2)`. Every non-mmap
477        // backend degrades gracefully to a plain positioned fd if the faster
478        // backend is refused, mirroring `build_block_sources`.
479        let point_source: Arc<dyn read_at::ReadAt> = match &scan_source {
480            ScanSource::Mapped(mmap) => Arc::new(read_at::MmapReadAt::new(mmap.clone())),
481            #[cfg(unix)]
482            ScanSource::Direct { .. } => match read_at::DirectReadAt::open(path, file_size) {
483                Ok(d) => Arc::new(d) as Arc<dyn read_at::ReadAt>,
484                Err(e) => {
485                    log::warn!(
486                        "Direct-I/O point source for {} failed ({}); using buffered pread",
487                        path.display(),
488                        e
489                    );
490                    Arc::new(read_at::PlainFileReadAt::open(path, file_size)?)
491                }
492            },
493            ScanSource::Buffered { .. } => {
494                Arc::new(read_at::PlainFileReadAt::open(path, file_size)?)
495            }
496        };
497
498        // Parse header - read available bytes, not a fixed size
499        // NOTE: For NB format files (Cassandra 4.x+), Data.db often contains compressed row data
500        // with no embedded header. The header.rs module detects this via filename pattern and
501        // returns a minimal header loaded from Statistics.db instead.
502        let header_size = std::cmp::min(4096, file_size as usize);
503        let mut header_buffer = vec![0u8; header_size];
504        {
505            let mut file_guard = file.lock().await;
506            let bytes_read = file_guard.read(&mut header_buffer).await?;
507            header_buffer.truncate(bytes_read);
508        }
509
510        // VG5 / Issue #831 / #909: BTI ("da") read support.
511        //
512        // BTI SSTables use a Partitions.db trie + Rows.db row index instead of
513        // Index.db/Summary.db. We load BOTH (tiny) tries fully into memory here so
514        // the point-lookup path (`lookup_partition_via_bti_trie` /
515        // `bti_point_lookup`) can walk them for O(log n) partition resolution:
516        //   - Partitions.db resolves a partition key to either a direct Data.db
517        //     offset (NARROW partition) or a positive RowsOffset (WIDE partition).
518        //   - Rows.db, indexed by that RowsOffset, recovers the wide partition's
519        //     Data.db position (issue #909/#910).
520        //
521        // Loading Partitions.db + Rows.db is the ONLY BTI-specific step in open():
522        // the rest of the flow (header / compression / Statistics-driven schema)
523        // tolerates the absent Index.db/Summary.db gracefully (those loaders
524        // return None).
525        let (bti_partitions_db, bti_rows_db) = if matches!(*version_gates, VersionGates::Bti(_)) {
526            let base = extract_sstable_base_name(path).ok_or_else(|| {
527                Error::unsupported_format(format!(
528                    "BTI (da) SSTable '{}' has a non-standard filename; cannot derive the \
529                         sibling Partitions.db name required for trie point lookup (#831).",
530                    path.display()
531                ))
532            })?;
533            let parent = path.parent().unwrap_or_else(|| Path::new("."));
534            let partitions_path = parent.join(format!("{}-Partitions.db", base));
535            let partitions_bytes = tokio::fs::read(&partitions_path).await.map_err(|e| {
536                Error::unsupported_format(format!(
537                    "BTI (da) SSTable '{}' is missing its sibling Partitions.db trie \
538                         (expected '{}'): {}. BTI read support requires Partitions.db for \
539                         partition-key point lookup (#831).",
540                    path.display(),
541                    partitions_path.display(),
542                    e
543                ))
544            })?;
545
546            // Rows.db carries the within-partition row index for WIDE
547            // partitions (issue #909/#910). It is ALWAYS emitted for a BTI
548            // SSTable (possibly 0 bytes for a narrow-only table), so a missing
549            // Rows.db is a structural error. A 0-byte file is valid: no
550            // partition resolved to a positive RowsOffset, so the point-lookup
551            // path never indexes into it.
552            let rows_path = parent.join(format!("{}-Rows.db", base));
553            let rows_bytes = tokio::fs::read(&rows_path).await.map_err(|e| {
554                Error::unsupported_format(format!(
555                    "BTI (da) SSTable '{}' is missing its sibling Rows.db row-index trie \
556                         (expected '{}'): {}. BTI read support requires Rows.db to resolve \
557                         wide-partition point lookups (#909/#910).",
558                    path.display(),
559                    rows_path.display(),
560                    e
561                ))
562            })?;
563
564            (Some(Arc::new(partitions_bytes)), Some(Arc::new(rows_bytes)))
565        } else {
566            let none: Option<Arc<Vec<u8>>> = None;
567            (none.clone(), none)
568        };
569
570        use tracing::Instrument;
571
572        let config = crate::cql::config::ParserConfig::default();
573        let parser = SSTableParser::new(config)?;
574        // Parse the header using enhanced version detection - strict error propagation.
575        // VersionGates are passed so VG3 can flip version-sensitive parsing decisions
576        // inside header parsing without re-deriving gates from the filename.
577        let header = parse_header_with_version_detection(&header_buffer, path, &version_gates)
578            .instrument(tracing::debug_span!("sstable.reader.open.header_parse"))
579            .await
580            .map_err(|e| {
581                Error::corruption(format!(
582                    "Failed to parse SSTable header for file '{}': {}. This indicates either \
583                     file corruption or an unsupported SSTable format. File size: {} bytes, \
584                     header buffer size: {} bytes.",
585                    path.display(),
586                    e,
587                    file_size,
588                    header_buffer.len()
589                ))
590            })?;
591        let header_size = calculate_actual_header_size(&header, &header_buffer)?;
592
593        // Schema extraction deferred until after Statistics.db columns are loaded (Issue #163)
594        // See schema extraction code after statistics_reader loading below
595
596        // Seek to start of data section
597        {
598            let mut file_guard = file.lock().await;
599            file_guard
600                .seek(std::io::SeekFrom::Start(header_size as u64))
601                .await?;
602        }
603
604        // Initialize compression reader with improved format detection
605        let compression_reader = detect_and_initialize_compression(&header, path).await?;
606
607        // Load CompressionInfo.db for chunked decompression (if it exists)
608        let compression_info = Self::load_compression_info_metadata(path, &platform).await?;
609
610        // Load CRC.db per-chunk checksums for uncompressed BIG read-time integrity
611        // (issue #1396). Only for uncompressed tables (compression_info None);
612        // compressed tables carry inline per-chunk CRCs instead.
613        let crc_reader = if compression_info.is_none() {
614            Self::load_crc_reader(path, &header, file_size).await?
615        } else {
616            None
617        };
618
619        // Pre-validate component architecture for better error handling
620        let components = Self::detect_component_files(path).await?;
621        if !components.is_empty() {
622            let integrity_issues = Self::validate_component_integrity(path, &components).await?;
623            if !integrity_issues.is_empty() {
624                log::warn!(
625                    "Component integrity issues detected but proceeding with loading: {:?}",
626                    integrity_issues
627                );
628            }
629        }
630
631        // Load index if available (supports both integrated and component-based)
632        let index = Self::load_index(&file, &header, &platform, path)
633            .instrument(tracing::debug_span!("sstable.reader.open.load_index"))
634            .await?;
635
636        // Load bloom filter if available (supports both integrated and component-based)
637        let bloom_filter = Self::load_bloom_filter(&file, &header, &platform, path)
638            .instrument(tracing::debug_span!("sstable.reader.open.load_bloom"))
639            .await?;
640
641        // Load spec readers for enhanced metadata and lookups
642        let index_reader = Self::load_index_reader(path, &platform)
643            .instrument(tracing::debug_span!(
644                "sstable.reader.open.load_index_reader"
645            ))
646            .await;
647        let summary_reader = Self::load_summary_reader(path, &platform)
648            .instrument(tracing::debug_span!("sstable.reader.open.load_summary"))
649            .await;
650        let statistics_reader = Self::load_statistics_reader(path, &platform)
651            .instrument(tracing::debug_span!("sstable.reader.open.load_statistics"))
652            .await?;
653
654        // Extract SerializationHeader columns from Statistics.db (Issue #163)
655        // This enables schema extraction for V5CompressedLegacy format
656        let mut header = header; // Make mutable to populate columns
657        if let Some(ref stats_reader) = statistics_reader {
658            let statistics = stats_reader.statistics();
659            let partition_columns = &statistics.serialization_header_partition_keys;
660            let clustering_columns = &statistics.serialization_header_clustering_keys;
661            let regular_columns = &statistics.serialization_header_columns;
662
663            if !partition_columns.is_empty()
664                || !clustering_columns.is_empty()
665                || !regular_columns.is_empty()
666            {
667                log::debug!(
668                    "Populating header columns from Statistics.db SerializationHeader: {} partition keys, {} clustering keys, {} regular columns",
669                    partition_columns.len(),
670                    clustering_columns.len(),
671                    regular_columns.len()
672                );
673
674                let mut merged_columns = Vec::with_capacity(
675                    partition_columns.len() + clustering_columns.len() + regular_columns.len(),
676                );
677                merged_columns.extend_from_slice(partition_columns);
678                merged_columns.extend_from_slice(clustering_columns);
679                merged_columns.extend_from_slice(regular_columns);
680
681                header.columns = merged_columns;
682            }
683        }
684
685        // Extract schema from header for V5.0+ formats (after columns are populated)
686        let schema = if matches!(
687            header.cassandra_version,
688            CassandraVersion::V5_0NewBig
689                | CassandraVersion::V5_0Bti
690                | CassandraVersion::V5_0DataFormat
691                | CassandraVersion::V5_0FormatC
692                | CassandraVersion::V5_0FormatD
693                | CassandraVersion::V5_0FormatE
694                | CassandraVersion::V5_0FormatF
695                | CassandraVersion::V5_0FormatG
696        ) {
697            match TableSchema::from_sstable_header(&header) {
698                Ok(s) => {
699                    log::debug!(
700                        "Extracted schema from SSTable header: {}.{} ({} columns, {} partition keys, {} clustering keys)",
701                        s.keyspace,
702                        s.table,
703                        s.columns.len(),
704                        s.partition_keys.len(),
705                        s.clustering_keys.len()
706                    );
707                    Some(Arc::new(s))
708                }
709                Err(e) => {
710                    log::warn!(
711                        "Failed to extract schema from SSTable header for {}: {}. Schema-aware parsing will not be available.",
712                        path.display(),
713                        e
714                    );
715                    None
716                }
717            }
718        } else {
719            // Legacy formats don't have schema in header
720            None
721        };
722
723        // Derive block_count from CompressionInfo.db when available — this is the
724        // authoritative source for compressed SSTables (no-heuristics mandate #28).
725        // Each entry in chunk_offsets corresponds to one compressed block in Data.db.
726        let block_count = compression_info
727            .as_ref()
728            .map(|ci| ci.chunk_offsets.len() as u64)
729            .unwrap_or(0);
730
731        let stats = SSTableReaderStats {
732            file_size,
733            entry_count: header.stats.row_count,
734            table_count: 1, // Will be updated as we discover tables
735            block_count,
736            index_size: 0,        // Will be updated if index is loaded
737            bloom_filter_size: 0, // Will be updated if bloom filter is loaded
738            compression_ratio: header.stats.compression_ratio,
739            cache_hit_rate: 0.0,
740        };
741
742        // Extract generation from filename or use default
743        let generation = extract_generation_from_path(path);
744
745        // Stable per-reader cache identity (issue #1567): hash the immutable
746        // file path + generation. Authoritative reader identity, never byte
747        // content — combined with a per-site salt to form the cache key.
748        let chunk_cache_id = {
749            use std::hash::{Hash, Hasher};
750            let mut h = std::collections::hash_map::DefaultHasher::new();
751            path.hash(&mut h);
752            generation.hash(&mut h);
753            h.finish()
754        };
755
756        Ok(Self {
757            file_path: path.to_path_buf(),
758            file,
759            scan_source,
760            point_source,
761            header,
762            parser,
763            index,
764            bloom_filter,
765            compression_reader,
766            block_meta_cache: FxHashMap::default(),
767            block_cache: FxHashMap::default(),
768            config: reader_config,
769            open_config,
770            platform,
771            stats,
772            cache_hits: AtomicU64::new(0),
773            cache_misses: AtomicU64::new(0),
774            #[cfg(feature = "tombstones")]
775            tombstone_merger: TombstoneMerger::new(),
776            generation,
777            actual_header_size: header_size,
778            index_reader,
779            summary_reader,
780            statistics_reader,
781            schema_registry: None, // Will be set by set_schema_registry() after construction
782            schema,
783            #[cfg(feature = "state_machine")]
784            registry_schema: None, // Resolved by resolve_registry_schema() at wiring time (#1692)
785            udt_registry: None, // Will be set when available for UDT-aware parsing
786            compression_info: compression_info.map(Arc::new),
787            crc_reader,
788            verified_uncompressed_chunks: std::sync::Mutex::new(std::collections::HashSet::new()),
789            version_gates,
790            bti_partitions_db,
791            bti_rows_db,
792            chunk_cache,
793            chunk_cache_id,
794            bti_partition_offsets: std::sync::OnceLock::new(),
795        })
796    }
797
798    /// Whether this reader's block source is backed by a memory map.
799    ///
800    /// Test-only hook used to verify that the `use_mmap` config / env wiring
801    /// actually selects the intended backend end-to-end.
802    #[cfg(test)]
803    pub(crate) async fn is_mmap_backed(&self) -> bool {
804        self.file.lock().await.is_mmap()
805    }
806
807    /// Clone the reader's shared positional point source (issue #1573 convoy
808    /// scenario). Test-only: lets a test wrap the real source in a slow/serializing
809    /// decorator, then reinstall it via [`set_point_source`](Self::set_point_source).
810    #[cfg(test)]
811    pub(crate) fn clone_point_source(&self) -> Arc<dyn read_at::ReadAt> {
812        self.point_source.clone()
813    }
814
815    /// Replace the reader's point source (issue #1573 convoy scenario). Test-only;
816    /// requires `&mut self`, so it must be called BEFORE the reader is shared
817    /// behind an `Arc` across the concurrent point reads under test.
818    #[cfg(test)]
819    pub(crate) fn set_point_source(&mut self, src: Arc<dyn read_at::ReadAt>) {
820        self.point_source = src;
821    }
822
823    /// Whether this reader's block source is backed by direct I/O.
824    ///
825    /// Test-only hook used to verify the `disk_access_mode` config / env wiring
826    /// selects the direct-I/O backend (issue: directio prefetch config).
827    #[cfg(all(test, unix))]
828    pub(crate) async fn is_direct_backed(&self) -> bool {
829        self.file.lock().await.is_direct()
830    }
831
832    /// Open a buffered point-read source and its per-scan factory. Shared by the
833    /// buffered backend and as the graceful fallback for mmap/direct.
834    async fn open_buffered_sources(
835        path: &Path,
836        file_size: u64,
837    ) -> Result<(BlockSource, ScanSource)> {
838        // A5 read-work counter (FILE_OPENS; consumer C2): one per open(2) that mints
839        // a reader fd — here the buffered cold-open / graceful-fallback site. No-op
840        // in release (design.md Decision 1/2).
841        crate::storage::sstable::read_work_counters::record_file_open();
842        Ok((
843            BlockSource::buffered_sized(File::open(path).await?, file_size),
844            ScanSource::Buffered {
845                file_len: file_size,
846            },
847        ))
848    }
849
850    /// Build the shared point-read [`BlockSource`] and the per-scan
851    /// [`ScanSource`] for a resolved [`DiskAccessMode`].
852    ///
853    /// `prefetch` selects read-ahead advice for mmap and (together with
854    /// `prefetch_bytes`) the direct-I/O read-ahead window. Each non-buffered
855    /// backend degrades gracefully to buffered I/O if the OS or filesystem
856    /// refuses it (mmap can fail on network mounts; direct I/O is unsupported on
857    /// some platforms/filesystems), so opening never fails purely because a
858    /// faster backend is unavailable.
859    async fn build_block_sources(
860        path: &Path,
861        file_size: u64,
862        mode: DiskAccessMode,
863        prefetch: PrefetchMode,
864        prefetch_bytes: usize,
865    ) -> Result<(BlockSource, ScanSource)> {
866        // With prefetch off, use the minimal aligned read-ahead the direct
867        // backend still requires (a single block, rounded up inside the cursor).
868        let direct_window = if matches!(prefetch, PrefetchMode::Off) {
869            1
870        } else {
871            prefetch_bytes
872        };
873        match mode {
874            DiskAccessMode::Buffered => Self::open_buffered_sources(path, file_size).await,
875            DiskAccessMode::Mmap => match Self::map_file(path) {
876                Ok(mmap) => {
877                    log::debug!(
878                        "Opened {} via memory map ({} bytes)",
879                        path.display(),
880                        file_size
881                    );
882                    // Best-effort read-ahead advice (Unix-only; madvise has no
883                    // Windows equivalent here). Failure is non-fatal.
884                    #[cfg(unix)]
885                    if let Some(advice) = mmap_advice_for(prefetch) {
886                        if let Err(e) = mmap.advise(advice) {
887                            log::debug!(
888                                "madvise({:?}) on {} failed: {}",
889                                advice,
890                                path.display(),
891                                e
892                            );
893                        }
894                    }
895                    let mmap = Arc::new(mmap);
896                    Ok((BlockSource::mapped(mmap.clone()), ScanSource::Mapped(mmap)))
897                }
898                Err(e) => {
899                    log::warn!(
900                        "Memory-mapping {} failed ({}); falling back to buffered I/O",
901                        path.display(),
902                        e
903                    );
904                    Self::open_buffered_sources(path, file_size).await
905                }
906            },
907            DiskAccessMode::Direct => {
908                #[cfg(unix)]
909                {
910                    match source::DirectCursor::open(path, direct_window) {
911                        Ok(cursor) => {
912                            log::debug!(
913                                "Opened {} via direct I/O ({} bytes, {}-byte window)",
914                                path.display(),
915                                file_size,
916                                direct_window
917                            );
918                            Ok((
919                                BlockSource::direct(cursor),
920                                ScanSource::Direct {
921                                    window: direct_window,
922                                    file_len: file_size,
923                                },
924                            ))
925                        }
926                        Err(e) => {
927                            log::warn!(
928                                "Direct I/O on {} failed ({}); falling back to buffered I/O",
929                                path.display(),
930                                e
931                            );
932                            Self::open_buffered_sources(path, file_size).await
933                        }
934                    }
935                }
936                #[cfg(not(unix))]
937                {
938                    let _ = direct_window;
939                    log::warn!(
940                        "Direct I/O is unavailable on this platform; using buffered I/O for {}",
941                        path.display()
942                    );
943                    Self::open_buffered_sources(path, file_size).await
944                }
945            }
946            // `resolve_disk_access_mode` never yields `Auto`; handle defensively.
947            DiskAccessMode::Auto => Self::open_buffered_sources(path, file_size).await,
948        }
949    }
950
951    /// Memory-map an SSTable file read-only.
952    ///
953    /// # Safety / correctness
954    ///
955    /// The returned [`Mmap`](memmap2::Mmap) aliases the file's bytes for its
956    /// entire lifetime. SSTables are immutable once written, and CQLite treats
957    /// them as read-only inputs, so this matches Cassandra's own mmap read
958    /// strategy. Mutating the underlying file while a reader is open is
959    /// undefined behaviour — callers must not do so.
960    ///
961    /// Note that only the initial mapping is fallible here. Once mapped, a later
962    /// page fault — caused by truncation, deletion, or a network/overlay
963    /// filesystem hiccup — raises `SIGBUS` and **cannot** be recovered as an
964    /// `io::Error`. This is why mmap is opt-in and gated on immutable local
965    /// files; see [`Config`]'s `storage.use_mmap` for the full constraints.
966    fn map_file(path: &Path) -> Result<memmap2::Mmap> {
967        // A5 read-work counter (FILE_OPENS; consumer C2): one per open(2) that mints
968        // a reader fd — here the mmap cold-open site. No-op in release (design.md
969        // Decision 1/2).
970        crate::storage::sstable::read_work_counters::record_file_open();
971        let std_file = std::fs::File::open(path)?;
972        // SAFETY: read-only mapping of a file assumed immutable for the
973        // reader's lifetime; see the function-level note above.
974        let mmap = unsafe { memmap2::MmapOptions::new().map(&std_file)? };
975        Ok(mmap)
976    }
977
978    /// Load CompressionInfo.db metadata for chunked reading
979    async fn load_compression_info_metadata(
980        path: &Path,
981        _platform: &Arc<Platform>,
982    ) -> Result<Option<CompressionInfo>> {
983        use tokio::fs::File;
984        use tokio::io::AsyncReadExt;
985
986        // Try to find CompressionInfo.db in same directory.
987        let parent_dir = path.parent().unwrap_or(Path::new("."));
988        // Derive the base name via the descriptor parser, which handles
989        // hyphenated-UUID ids (e.g. "da-00000000-0000-0000-0000-000000000001-bti").
990        // A fixed parts[0..3] split mangles those and looks for the wrong
991        // "*-CompressionInfo.db", silently treating compressed data as
992        // uncompressed (roborev #970).
993        let base_name = crate::storage::sstable::version_gate::SsTableDescriptor::parse(path)
994            .ok()
995            .map(|d| format!("{}-{}-{}", d.version, d.sstable_id, d.format.as_str()));
996
997        if let Some(base) = base_name {
998            let compression_info_path = parent_dir.join(format!("{}-CompressionInfo.db", base));
999            if compression_info_path.exists() {
1000                let mut file = File::open(&compression_info_path).await?;
1001                let mut buffer = Vec::new();
1002                file.read_to_end(&mut buffer).await?;
1003
1004                // Fail-fast (issue #1001): a CompressionInfo.db that is present but
1005                // names an unknown/unsupported compressor (or is otherwise malformed)
1006                // MUST hard-error at reader-open time, BEFORE any Data.db chunk is read —
1007                // never silently fall back to the uncompressed path. The error carries
1008                // the offending component path for diagnosis. A genuinely uncompressed
1009                // SSTable has no CompressionInfo.db at all and returns Ok(None) below.
1010                let info = CompressionInfo::parse(&buffer).map_err(|e| {
1011                    Error::UnsupportedFormat(format!(
1012                        "Failed to parse CompressionInfo.db at {}: {}",
1013                        compression_info_path.display(),
1014                        e
1015                    ))
1016                })?;
1017                log::debug!(
1018                    "Loaded CompressionInfo: algorithm={}, chunk_length={}, chunks={}",
1019                    info.algorithm,
1020                    info.chunk_length,
1021                    info.chunk_offsets.len()
1022                );
1023                return Ok(Some(info));
1024            }
1025        }
1026
1027        Ok(None)
1028    }
1029
1030    /// Load the `CRC.db` per-chunk checksum sidecar for read-time integrity of
1031    /// **uncompressed** BIG SSTables (issue #1396).
1032    ///
1033    /// Called only when `compression_info` is `None` (compressed tables carry
1034    /// inline per-chunk CRCs and no `CRC.db`). Returns:
1035    /// - `Some(crc)` for an uncompressed BIG SSTable that ships a `CRC.db`
1036    ///   (Cassandra writes one for every uncompressed BIG table) — verification is
1037    ///   then default-on for every uncompressed chunk read.
1038    /// - `None` for BTI (`da`) tables (Cassandra emits no `CRC.db`) or an
1039    ///   uncompressed BIG table whose `CRC.db` is absent. The absent case is the
1040    ///   owner-pinned **warn-and-proceed** decision (design D4): a `log::warn!` is
1041    ///   emitted so the missing integrity component is visible, and the read
1042    ///   proceeds unverified rather than hard-failing.
1043    ///
1044    /// A present-but-malformed `CRC.db` is a hard [`Error`] at open time (never a
1045    /// silent fall-through to unverified reads), mirroring the CompressionInfo.db
1046    /// fail-fast posture (#1001).
1047    async fn load_crc_reader(
1048        path: &Path,
1049        header: &SSTableHeader,
1050        data_len: u64,
1051    ) -> Result<Option<Arc<crc::CrcDb>>> {
1052        // BTI (`da`) never ships a CRC.db; nothing to load.
1053        if matches!(header.cassandra_version, CassandraVersion::V5_0Bti) {
1054            return Ok(None);
1055        }
1056
1057        let parent_dir = path.parent().unwrap_or(Path::new("."));
1058        let Some(base) = compression::extract_sstable_base_name(path) else {
1059            return Ok(None);
1060        };
1061        let crc_path = parent_dir.join(format!("{}-CRC.db", base));
1062        if !crc_path.exists() {
1063            // Absent CRC.db on an uncompressed BIG SSTable: warn-and-proceed
1064            // (owner-pinned, design D4). Cassandra 5.0 writes a CRC.db for every
1065            // uncompressed BIG SSTable, so its absence is notable but not fatal —
1066            // reads proceed unverified rather than hard-failing.
1067            log::warn!(
1068                "CRC.db absent for uncompressed SSTable {} — proceeding without \
1069                 read-time per-chunk CRC verification (warn-and-proceed, issue #1396)",
1070                path.display()
1071            );
1072            return Ok(None);
1073        }
1074
1075        let crc = crc::CrcDb::open(&crc_path, data_len).await.map_err(|e| {
1076            Error::corruption(format!(
1077                "Failed to parse CRC.db at {}: {}",
1078                crc_path.display(),
1079                e
1080            ))
1081        })?;
1082        log::debug!(
1083            "Loaded CRC.db for {}: chunk_size={}, chunks={}",
1084            path.display(),
1085            crc.chunk_size(),
1086            crc.chunk_count()
1087        );
1088        Ok(Some(Arc::new(crc)))
1089    }
1090
1091    /// Set the schema registry for schema-driven operations.
1092    ///
1093    /// This is a SYNC method (`&mut self`, non-async), so it CANNOT await the
1094    /// async registry to pre-resolve the sync schema-fallback cache
1095    /// ([`registry_schema`](Self::registry_schema)) — doing so would require a
1096    /// `block_on`, which #1692 (AG3) forbids on a tokio worker thread. It
1097    /// therefore only stores the registry and INVALIDATES any previously
1098    /// pre-resolved cache (the new registry may resolve a different schema).
1099    ///
1100    /// Direct callers that intend to trigger a SYNC parse (whose schema-fallback
1101    /// tier reads `registry_schema`) must, from an async context, either call the
1102    /// combined [`attach_schema_registry`](Self::attach_schema_registry) instead,
1103    /// or call [`resolve_registry_schema`](Self::resolve_registry_schema) after
1104    /// this. Otherwise the sync path deliberately falls through to
1105    /// header-column construction (or `None`). The crate's own async wiring path
1106    /// (`open_reader_with_schema`) already does this.
1107    #[cfg(feature = "state_machine")]
1108    #[deprecated(
1109        note = "attaches the registry but CANNOT pre-resolve the sync schema-fallback cache \
1110                (would need a forbidden block_on, issue #1692); registry schemas will not be \
1111                available to a subsequent sync `get_table_schema`. Use the async \
1112                `attach_schema_registry` (which pre-resolves), or call `resolve_registry_schema` \
1113                after this from an async context, for registry-schema-aware reads."
1114    )]
1115    pub fn set_schema_registry(
1116        &mut self,
1117        schema_registry: Arc<tokio::sync::RwLock<crate::schema::SchemaRegistry>>,
1118    ) {
1119        self.schema_registry = Some(schema_registry);
1120        // Invalidate the sync fallback cache: a prior registry (if any) may have
1121        // resolved a schema that no longer applies. It is re-populated only by an
1122        // explicit `resolve_registry_schema()` / `attach_schema_registry()`.
1123        self.registry_schema = None;
1124        log::debug!(
1125            "Schema registry set for {}.{} - enabling schema-driven digest computation",
1126            self.header.keyspace,
1127            self.header.table_name
1128        );
1129    }
1130
1131    /// Attach a schema registry AND pre-resolve it into the sync fallback cache
1132    /// (issue #1692). Async wiring convenience for DIRECT callers: it combines
1133    /// [`set_schema_registry`](Self::set_schema_registry) with
1134    /// [`resolve_registry_schema`](Self::resolve_registry_schema) so the sync
1135    /// `get_table_schema` fallback tier is populated without any `block_on`.
1136    ///
1137    /// Prefer this over the bare sync `set_schema_registry` whenever you attach a
1138    /// registry to a reader you will parse synchronously.
1139    #[cfg(feature = "state_machine")]
1140    pub async fn attach_schema_registry(
1141        &mut self,
1142        schema_registry: Arc<tokio::sync::RwLock<crate::schema::SchemaRegistry>>,
1143    ) {
1144        // Intentional internal use of the sync attach step; we immediately
1145        // pre-resolve below, which is exactly what the deprecation directs.
1146        #[allow(deprecated)]
1147        self.set_schema_registry(schema_registry);
1148        self.resolve_registry_schema().await;
1149    }
1150
1151    /// Set the schema registry for schema-driven operations (non-state_machine builds)
1152    #[cfg(not(feature = "state_machine"))]
1153    pub fn set_schema_registry(&mut self, schema_registry: Arc<crate::schema::SchemaRegistry>) {
1154        self.schema_registry = Some(schema_registry);
1155        log::debug!(
1156            "Schema registry set for {}.{} - enabling schema-driven digest computation",
1157            self.header.keyspace,
1158            self.header.table_name
1159        );
1160    }
1161
1162    /// Set the UDT registry for UDT-aware parsing in collections
1163    ///
1164    /// This enables proper parsing of UDTs inside collections (List, Set, Map)
1165    /// by providing the UDT field definitions needed for nested type resolution.
1166    pub fn set_udt_registry(&mut self, registry: crate::schema::UdtRegistry) {
1167        self.udt_registry = Some(registry);
1168        log::debug!(
1169            "UDT registry set for {}.{} - enabling UDT-aware collection parsing",
1170            self.header.keyspace,
1171            self.header.table_name
1172        );
1173    }
1174
1175    /// Get reader statistics
1176    pub async fn stats(&self) -> Result<&SSTableReaderStats> {
1177        Ok(&self.stats)
1178    }
1179
1180    /// Close the reader and release resources
1181    pub async fn close(mut self) -> Result<()> {
1182        debug!("Closing SSTable reader for {:?}", self.file_path);
1183
1184        // Clear caches and log cache statistics
1185        let cache_entries = self.block_cache.len();
1186        let meta_entries = self.block_meta_cache.len();
1187
1188        self.block_cache.clear();
1189        self.block_meta_cache.clear();
1190
1191        debug!(
1192            "Cleared {} block cache entries and {} metadata entries",
1193            cache_entries, meta_entries
1194        );
1195
1196        // File will be closed automatically when dropped
1197        Ok(())
1198    }
1199
1200    /// Calculate header size based on format and actual header content
1201    pub fn calculate_header_size(&self) -> usize {
1202        self.actual_header_size
1203    }
1204
1205    /// Get the Cassandra version from the SSTable header
1206    pub fn cassandra_version(&self) -> CassandraVersion {
1207        self.header.cassandra_version
1208    }
1209
1210    /// Low-cardinality on-disk format family label for telemetry attributes
1211    /// (`"bti"` or `"big"`). Derived from the authoritative [`VersionGates`], so
1212    /// it stays bounded to exactly two values — safe to attach to metrics. NOT a
1213    /// substitute for [`format_version`](Self::format_version), which returns the
1214    /// exact on-disk version token.
1215    pub(crate) fn sstable_format_label(&self) -> &'static str {
1216        match *self.version_gates {
1217            VersionGates::Bti(_) => "bti",
1218            VersionGates::Big(_) => "big",
1219        }
1220    }
1221
1222    /// Whether the on-disk `DeletionTime` uses the Cassandra 5.0 UNSIGNED
1223    /// `localDeletionTime` serializer (`oa`/`da`, `hasUIntDeletionTime`) rather
1224    /// than the legacy SIGNED `i32` form (`nb`).
1225    ///
1226    /// The verifier uses this to interpret a raw partition-level
1227    /// `localDeletionTime`: on the legacy signed form a NEGATIVE value (that is
1228    /// not the `i32::MAX` live sentinel) is unambiguously corrupt, whereas on the
1229    /// unsigned form a value in `[2^31, 2^32)` is a legitimate far-future
1230    /// deletion time and must NOT be flagged (no heuristics — the format decides).
1231    ///
1232    /// Authority: `BigFormat.java:409` (`hasUIntDeletionTime`), `DeletionTime.java`.
1233    pub fn has_uint_deletion_time(&self) -> bool {
1234        match *self.version_gates {
1235            VersionGates::Big(ref g) => g.has_uint_deletion_time,
1236            VersionGates::Bti(ref g) => g.has_uint_deletion_time,
1237        }
1238    }
1239
1240    /// Get the SSTable format version string
1241    pub fn format_version(&self) -> Result<String> {
1242        let filename = self
1243            .file_path
1244            .file_name()
1245            .and_then(|f| f.to_str())
1246            .ok_or_else(|| {
1247                Error::InvalidPath(format!("Invalid SSTable filename: {:?}", self.file_path))
1248            })?;
1249
1250        let parts: Vec<&str> = filename.split('-').collect();
1251        if parts.is_empty() {
1252            return Err(Error::InvalidFormat(format!(
1253                "Cannot extract format version from filename: {}",
1254                filename
1255            )));
1256        }
1257
1258        Ok(parts[0].to_string())
1259    }
1260
1261    /// Get a reference to the SSTable header
1262    pub fn header(&self) -> &SSTableHeader {
1263        &self.header
1264    }
1265
1266    /// Get the table schema extracted from the SSTable header
1267    ///
1268    /// Returns `None` for legacy formats or if schema extraction failed.
1269    pub fn schema(&self) -> Option<&TableSchema> {
1270        self.schema.as_deref()
1271    }
1272
1273    /// The effective table schema the read/verify paths decode with — the same
1274    /// four-tier resolution `partition_clustering_verify_scan` uses (issue #1282).
1275    ///
1276    /// Returned owned because the registry-fallback tier constructs a schema; the
1277    /// verifier needs it to drive the authoritative clustering comparator
1278    /// ([`crate::storage::write_engine::mutation::ClusteringKey::compare`]).
1279    pub fn effective_schema(&self) -> Option<TableSchema> {
1280        self.get_table_schema(None)
1281    }
1282
1283    /// Extract write time from entry metadata
1284    pub fn extract_write_time_from_entry(&self, _key: &RowKey, row: &ScanRow) -> i64 {
1285        use log::warn;
1286
1287        match row {
1288            ScanRow::Marker(Value::Tombstone(info)) => info.deletion_time,
1289            _ => std::time::SystemTime::now()
1290                .duration_since(std::time::UNIX_EPOCH)
1291                .map(|d| d.as_micros() as i64)
1292                .unwrap_or_else(|e| {
1293                    warn!("Failed to get system time: {}; using fallback value 0", e);
1294                    0
1295                }),
1296        }
1297    }
1298}
1299
1300impl Drop for SSTableReader {
1301    fn drop(&mut self) {
1302        use crate::observability::{self as obs, catalog};
1303        // Keep the per-format live-reader count and the SSTABLES_OPEN gauge in
1304        // sync as readers are released. Use a single atomic read-modify-write
1305        // (`fetch_sub`) on the SAME per-format counter `open()` incremented: a
1306        // load-then-store would race concurrent drops and lose decrements,
1307        // drifting the gauge permanently high. `open()` only ever increments on
1308        // success, so this never underflows in practice.
1309        let format = self.sstable_format_label();
1310        let now = sstables_open_count_for(format).fetch_sub(1, Ordering::Relaxed) - 1;
1311        obs::record_gauge(
1312            catalog::SSTABLES_OPEN,
1313            now,
1314            &[(catalog::attr::SSTABLE_FORMAT, format.into())],
1315        );
1316    }
1317}
1318
1319impl std::fmt::Debug for SSTableReader {
1320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1321        f.debug_struct("SSTableReader")
1322            .field("file_path", &self.file_path)
1323            .field("header", &self.header)
1324            .field("has_index", &self.index.is_some())
1325            .field("has_bloom_filter", &self.bloom_filter.is_some())
1326            .field("compression", &self.header.compression.algorithm)
1327            .field("stats", &self.stats)
1328            .finish()
1329    }
1330}
1331
1332/// Helper function to create a reader with default configuration
1333pub async fn open_sstable_reader(
1334    path: &Path,
1335    config: &Config,
1336    platform: Arc<Platform>,
1337) -> Result<SSTableReader> {
1338    SSTableReader::open(path, config, platform).await
1339}