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 bti_lookup_memo;
14/// Single chunk decode plane (issue #1598, Epic G / G2).
15mod chunk_source;
16/// Per-element / per-cell compaction read contract (epic #899, Phase A).
17pub mod compaction_row;
18mod component_loading;
19mod compression;
20/// `CRC.db` reader for uncompressed BIG read-time integrity (issue #1396).
21pub(crate) mod crc;
22mod data_access;
23/// Delta-scan record model (Epic #696, Issue #697).
24/// Only compiled when the `delta-scan` feature is enabled.
25#[cfg(feature = "delta-scan")]
26pub mod delta_scan;
27mod header;
28mod header_helpers;
29mod integrity;
30mod key_digest;
31pub(crate) mod parsing; // Needs to be accessible from row_cell_state_machine
32mod partition_lookup;
33pub mod presence_verification; // #2163 opt-in false-negative verification switch
34                               // One format-tagged partition-location façade (issue #1599 / G3): `locate`
35                               // composes the C5 range short-circuit + per-format offset resolve.
36mod partition_locator;
37// Next-partition (successor) seek-window offset resolution (issue #953 / #951),
38// split out of `partition_lookup` for the campsite source-size rule (#1116).
39mod partition_successor;
40// Positional (`pread`-style) point-read backends (issue #1573, Epic C / C2).
41mod read_at;
42// Concurrency scenarios for the ReadAt point-read migration (issue #1573).
43#[cfg(test)]
44mod read_at_point_tests;
45// Direct-I/O read-ahead window sizing (issue #1596, Epic F / F6): clamps a
46// sub-chunk `direct_io_prefetch_bytes` so one compression chunk never straddles
47// more than two aligned refills.
48mod prefetch_window;
49// sync-fallback registry-schema pre-resolution (issue #1692)
50#[cfg(feature = "state_machine")]
51mod registry_schema;
52// Windowed streaming-scan driver (issue #1143); `pub` ONLY under non-default
53// `scan-offload-probe` so the #1143 guard reaches its probe, else private.
54#[cfg(not(feature = "scan-offload-probe"))]
55pub(crate) mod scan_stream_windowed;
56#[cfg(feature = "scan-offload-probe")]
57pub mod scan_stream_windowed;
58mod source;
59mod summary_point; // #2412 §B: Summary-guided bounded-interval BIG point lookup
60#[cfg(test)]
61mod tests;
62mod types;
63// Sliding-window byte cursor + its test-only byte-movement probe (issue #1589);
64// `pub` ONLY under the non-default `scan-offload-probe` feature so the guard test
65// reaches `window_cursor::probe`, else crate-private.
66#[cfg(not(feature = "scan-offload-probe"))]
67pub(crate) mod window_cursor;
68#[cfg(feature = "scan-offload-probe")]
69pub mod window_cursor;
70// Active-window decode borrow source (issue #1644, K5 stage 2) — localizes the
71// borrow-vs-copy decision to one place without threading a window handle
72// through the whole decode call graph. See module docs.
73pub(crate) mod value_borrow;
74
75// Re-export public types
76pub use types::{
77    BlockMeta, IntegrityCheckResult, IntegrityStatus, SSTableReader, SSTableReaderConfig,
78    SSTableReaderHealthMetrics, SSTableReaderStats,
79};
80// Re-export the within-partition clustering-slice push-down spec (Issue #954).
81pub use data_access::ClusteringSlice;
82// Token-range bound pushed into the Summary-guided streaming walk (issue #2413
83// Option A) — used by the flight warm merge to scope a split's scan.
84pub use data_access::ScanTokenBound;
85// Single-partition compaction seek outcome (issue #2207). `not(tombstones)` like
86// the seek path it wraps.
87#[cfg(not(feature = "tombstones"))]
88pub use data_access::SinglePartitionCompaction;
89// Re-export the per-element compaction read contract (epic #899, Phase A).
90pub use compaction_row::{
91    CompactionRow, CompactionRowData, ComplexColumn, ComplexElement, SimpleCell,
92};
93// Re-export V5CompressedLegacyParser for integration testing (Issue #166 regression tests)
94#[doc(hidden)]
95pub use parsing::PublicV5CompressedLegacyParser as V5CompressedLegacyParser;
96
97// Re-export compression utilities for testing (Issue #202)
98#[doc(hidden)]
99pub use compression::extract_sstable_base_name;
100
101// Internal imports from submodules
102use header::{
103    calculate_actual_header_size, extract_generation_from_path, parse_header_with_version_detection,
104};
105
106use std::path::{Path, PathBuf};
107use std::sync::atomic::{AtomicI64, Ordering};
108use std::sync::Arc;
109use tokio::fs::File;
110use tokio::io::{AsyncReadExt, AsyncSeekExt};
111use tokio::sync::Mutex;
112
113use source::{BlockSource, ScanSource};
114
115use crate::{
116    config::{DiskAccessMode, PrefetchMode},
117    parser::{header::CassandraVersion, SSTableHeader, SSTableParser},
118    platform::Platform,
119    schema::TableSchema,
120    storage::sstable::{
121        compression::CompressionReader,
122        compression_info::CompressionInfo,
123        version_gate::{BigVersionGates, VersionGates},
124    },
125    Config, Error, Result, RowKey, ScanRow, Value,
126};
127
128// Structured logging
129use tracing::debug;
130
131#[cfg(feature = "tombstones")]
132use super::tombstone_merger::TombstoneMerger;
133
134/// The `Index.db` hardlink SIBLING of a `Data.db` path (issue #2356 roborev),
135/// i.e. the `*-Data.db` name-suffix swapped for `*-Index.db` in the same
136/// directory. `None` when the name is not the expected `*-Data.db` shape. Used
137/// by [`SSTableReader::rebind_path`] to follow a #2383 inode-rebind for the lazy
138/// `Index.db` path, and by the streaming-walk `current_index_db_path` derivation
139/// (same rule) so every `Index.db` consumer stays on the rebound generation.
140pub(crate) fn index_db_sibling(data_path: &Path) -> Option<PathBuf> {
141    let parent = data_path.parent()?;
142    let name = data_path.file_name().and_then(|n| n.to_str())?;
143    let base = name.strip_suffix("-Data.db")?;
144    Some(parent.join(format!("{base}-Index.db")))
145}
146
147/// Returns `true` when memory-mapped reads are force-enabled via the
148/// `CQLITE_USE_MMAP` environment variable.
149///
150/// Accepts `1`, `true`, `yes`, `on` (case-insensitive). Any other value — or
151/// an unset variable — leaves the decision to [`Config`]. This is an opt-in
152/// escape hatch for ad-hoc local use without threading a custom config.
153fn mmap_enabled_via_env() -> bool {
154    std::env::var("CQLITE_USE_MMAP")
155        .ok()
156        .as_deref()
157        .map(parse_truthy_env)
158        .unwrap_or(false)
159}
160
161/// Parse a truthy environment-variable value (`1`/`true`/`yes`/`on`,
162/// case-insensitive). Split out so it can be unit-tested without mutating the
163/// process-global environment (which would race other `open()` tests).
164fn parse_truthy_env(value: &str) -> bool {
165    matches!(
166        value.trim().to_ascii_lowercase().as_str(),
167        "1" | "true" | "yes" | "on"
168    )
169}
170
171/// Parse a [`DiskAccessMode`] from a string (`auto`/`buffered`/`mmap`/`direct`,
172/// case-insensitive). Returns `None` for unrecognized values so callers can
173/// keep the configured default. Pure for unit-testing without env mutation.
174fn parse_disk_access_mode(value: &str) -> Option<DiskAccessMode> {
175    match value.trim().to_ascii_lowercase().as_str() {
176        "auto" => Some(DiskAccessMode::Auto),
177        "buffered" | "buffer" => Some(DiskAccessMode::Buffered),
178        "mmap" | "mapped" => Some(DiskAccessMode::Mmap),
179        "direct" | "directio" | "direct_io" | "o_direct" => Some(DiskAccessMode::Direct),
180        _ => None,
181    }
182}
183
184/// Parse a [`PrefetchMode`] from a string (`off`/`sequential`/`willneed`/`auto`).
185/// Returns `None` for unrecognized values. Pure for unit-testing.
186fn parse_prefetch_mode(value: &str) -> Option<PrefetchMode> {
187    match value.trim().to_ascii_lowercase().as_str() {
188        "off" | "none" | "no" => Some(PrefetchMode::Off),
189        "sequential" | "seq" => Some(PrefetchMode::Sequential),
190        "willneed" | "will_need" | "will-need" => Some(PrefetchMode::WillNeed),
191        "auto" => Some(PrefetchMode::Auto),
192        _ => None,
193    }
194}
195
196/// `CQLITE_DISK_ACCESS_MODE` override, if set to a recognized value.
197fn disk_access_mode_via_env() -> Option<DiskAccessMode> {
198    std::env::var("CQLITE_DISK_ACCESS_MODE")
199        .ok()
200        .as_deref()
201        .and_then(parse_disk_access_mode)
202}
203
204/// `CQLITE_PREFETCH` override, if set to a recognized value.
205fn prefetch_mode_via_env() -> Option<PrefetchMode> {
206    std::env::var("CQLITE_PREFETCH")
207        .ok()
208        .as_deref()
209        .and_then(parse_prefetch_mode)
210}
211
212/// Best-effort total physical RAM in bytes, or `None` when it cannot be
213/// determined on this platform. Used by [`DiskAccessMode::Auto`] to decide when
214/// a file is large enough to warrant page-cache-bypassing direct I/O.
215fn system_memory_bytes() -> Option<u64> {
216    #[cfg(unix)]
217    {
218        // SAFETY: `sysconf` is a pure query with no pointer arguments.
219        let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
220        let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
221        if pages > 0 && page_size > 0 {
222            return Some((pages as u64).saturating_mul(page_size as u64));
223        }
224        None
225    }
226    #[cfg(not(unix))]
227    {
228        None
229    }
230}
231
232/// Decide which disk-access backend to use for a Data.db file.
233///
234/// Pure function (system memory injected) so the [`DiskAccessMode::Auto`]
235/// heuristic can be unit-tested deterministically. Resolution rules:
236/// - explicit `Buffered`/`Mmap`/`Direct` are returned unchanged (the caller
237///   applies graceful fallback if the OS refuses the backend);
238/// - `Auto` returns `Buffered` for files below `mmap_min_size_bytes`, `Direct`
239///   when the file exceeds `memory_fraction` of `system_memory` (and memory is
240///   known and direct I/O is available on this platform), otherwise `Mmap`.
241///
242/// The deprecated `use_mmap` flag / `CQLITE_USE_MMAP` env is folded in by the
243/// caller (it promotes a `Buffered` request to `Mmap`), so it is not an input
244/// here; an explicit mode always takes precedence.
245fn resolve_disk_access_mode(
246    configured: DiskAccessMode,
247    file_size: u64,
248    mmap_min_size_bytes: u64,
249    memory_fraction: f64,
250    system_memory: Option<u64>,
251    direct_io_available: bool,
252) -> DiskAccessMode {
253    // Zero-length files cannot be mapped and have nothing to read directly;
254    // always use buffered I/O for them regardless of the requested mode.
255    if file_size == 0 {
256        return DiskAccessMode::Buffered;
257    }
258    match configured {
259        DiskAccessMode::Buffered => DiskAccessMode::Buffered,
260        DiskAccessMode::Mmap => DiskAccessMode::Mmap,
261        DiskAccessMode::Direct => DiskAccessMode::Direct,
262        DiskAccessMode::Auto => {
263            if file_size < mmap_min_size_bytes {
264                return DiskAccessMode::Buffered;
265            }
266            let fraction = if memory_fraction.is_finite() && memory_fraction > 0.0 {
267                memory_fraction.min(1.0)
268            } else {
269                0.5
270            };
271            if direct_io_available {
272                if let Some(mem) = system_memory {
273                    let threshold = (mem as f64 * fraction) as u64;
274                    if file_size > threshold {
275                        return DiskAccessMode::Direct;
276                    }
277                }
278            }
279            DiskAccessMode::Mmap
280        }
281    }
282}
283
284/// Whether the direct-I/O backend is compiled in for this platform.
285const fn direct_io_available() -> bool {
286    cfg!(all(
287        unix,
288        any(
289            target_os = "linux",
290            target_os = "android",
291            target_os = "macos"
292        )
293    ))
294}
295
296/// Resolve a [`PrefetchMode`] into the concrete advice the mmap backend should
297/// issue, or `None` for "no advice".
298///
299/// `memmap2::Advice` / `Mmap::advise` (madvise) are Unix-only, so this and its
300/// single call site are gated to `#[cfg(unix)]`. On non-Unix targets the mmap
301/// backend simply issues no read-ahead advice.
302///
303/// [`PrefetchMode::Auto`] deliberately issues **no** madvise (issue #1143).
304/// `MADV_SEQUENTIAL` couples aggressive read-ahead with *drop-behind*: pages are
305/// evicted from the page cache as soon as the scan moves past them. In isolation
306/// that is fine (mmap scans are ~40% faster than buffered), but under concurrent
307/// write load the page-cache pressure means the just-dropped pages are gone by
308/// the time an overlapping scan needs them again, so re-reads take *synchronous*
309/// major page faults on the tokio worker thread and the read-side p99 tail blows
310/// up (~2x regression). Relying on the kernel's default read-ahead (no
311/// drop-behind) keeps the isolated mmap win while letting the page cache retain
312/// hot pages, which collapses that tail. Callers who genuinely want the
313/// drop-behind behaviour can still request `Sequential` explicitly
314/// (`CQLITE_PREFETCH=sequential` / [`StorageConfig::prefetch`]).
315#[cfg(unix)]
316fn mmap_advice_for(prefetch: PrefetchMode) -> Option<memmap2::Advice> {
317    match prefetch {
318        // No madvise: rely on the kernel's default read-ahead. Chosen for `Auto`
319        // to avoid `MADV_SEQUENTIAL` drop-behind evicting hot pages under
320        // concurrent write load (issue #1143).
321        PrefetchMode::Off | PrefetchMode::Auto => None,
322        // Explicit opt-in to aggressive read-ahead + drop-behind. Best for a
323        // one-shot full scan that will not be re-read and should not pin the
324        // whole file in the page cache.
325        PrefetchMode::Sequential => Some(memmap2::Advice::Sequential),
326        PrefetchMode::WillNeed => Some(memmap2::Advice::WillNeed),
327    }
328}
329
330/// Minimum SSTable size for the point-read path to get its OWN mmap advised
331/// `MADV_RANDOM` (issue #2210). Below this the point source shares the scan's
332/// `Arc<Mmap>` unchanged (no 2nd mapping, no advice): the whole file is small
333/// enough that the kernel's default read-ahead cheaply makes it resident, and
334/// `MADV_RANDOM` would only add per-page synchronous faults. Above it, scattered
335/// point-lookup faults otherwise waste the ~128 KiB read-ahead window per read,
336/// so a dedicated `MADV_RANDOM` mapping collapses both the block-I/O
337/// amplification (~30x) and the cold-cache per-read latency (~35-43%). Threshold
338/// is measurement-derived on Linux/EBS: the win is unambiguous by 4 MiB; 8 MiB
339/// leaves 2x margin above the sub-MB "wash" zone. See
340/// docs/reports/issue-2210-madv-random-point-mmap-ab.md. The SCAN mapping is
341/// NEVER advised (measured #1143 behaviour preserved).
342#[cfg(unix)]
343const POINT_MMAP_MADV_RANDOM_MIN_BYTES: u64 = 8 * 1024 * 1024;
344
345/// Process-wide count of currently-open [`SSTableReader`]s, the source value
346/// for the [`SSTABLES_OPEN`](crate::observability::catalog::SSTABLES_OPEN)
347/// gauge. Tracked PER FORMAT so the gauge — which carries the
348/// [`cqlite.sstable.format`](crate::observability::catalog::attr::SSTABLE_FORMAT)
349/// attribute — reports the correct live count for each `big`/`bti` label series
350/// instead of a single global total stamped onto every series. Incremented when
351/// [`SSTableReader::open`] succeeds and decremented when a reader is dropped, so
352/// each gauge series reflects live readers regardless of the `observability`
353/// feature state (the helper calls are no-ops when off).
354static SSTABLES_OPEN_COUNT_BIG: AtomicI64 = AtomicI64::new(0);
355static SSTABLES_OPEN_COUNT_BTI: AtomicI64 = AtomicI64::new(0);
356
357/// Select the per-format open-reader counter for a `sstable_format_label()`
358/// value (`"big"` / `"bti"`). Defaults to the BIG counter for any unexpected
359/// label so the value is never silently dropped; the label set is bounded to
360/// the two [`sstable_format_label`](SSTableReader::sstable_format_label) returns.
361fn sstables_open_count_for(format: &str) -> &'static AtomicI64 {
362    match format {
363        "bti" => &SSTABLES_OPEN_COUNT_BTI,
364        _ => &SSTABLES_OPEN_COUNT_BIG,
365    }
366}
367
368impl SSTableReader {
369    /// Open an SSTable file for reading.
370    ///
371    /// Instrumented (epic #1031 / #1034): wraps the open in a
372    /// `sstable.reader.open` span, increments the [`SSTABLES_OPEN`] gauge on
373    /// success, and records an error on the `reader` subsystem when open fails.
374    ///
375    /// [`SSTABLES_OPEN`]: crate::observability::catalog::SSTABLES_OPEN
376    pub async fn open(path: &Path, config: &Config, platform: Arc<Platform>) -> Result<Self> {
377        // Back-compat: existing callers and sibling crates get a FRESH per-reader
378        // decompressed-chunk cache sized from config (issue #1567). Production
379        // reads route through `SSTableManager`, which calls `open_with_cache` with
380        // its SHARED instance so all readers of a dataset share one cache.
381        //
382        // Honor the `config.memory.block_cache.enabled` toggle (issue #1568): when
383        // disabled this yields a genuine no-op cache so the direct reader path
384        // bypasses caching identically to the manager path, instead of the toggle
385        // being decorative here. Reuses the shared `build_chunk_cache` helper.
386        let cache = super::build_chunk_cache(config);
387        Self::open_with_cache(path, config, platform, cache).await
388    }
389
390    /// Cancel-aware [`open`](Self::open) (issue #2383 fix C).
391    ///
392    /// Threads a synchronous [`ScanCancel`](crate::storage::scan_cancel::ScanCancel)
393    /// into the Index.db partition-index parse so a client-disconnect cancel
394    /// aborts a 1.58M-entry parse within one poll interval instead of pinning a
395    /// tokio worker to completion. Non-cancellable callers use [`open`](Self::open)
396    /// (a default never-cancel flag). Returns [`Error::Cancelled`] on a mid-parse
397    /// trip.
398    pub async fn open_cancellable(
399        path: &Path,
400        config: &Config,
401        platform: Arc<Platform>,
402        cancel: crate::storage::scan_cancel::ScanCancel,
403    ) -> Result<Self> {
404        let cache = super::build_chunk_cache(config);
405        Self::open_with_cache_cancellable(path, config, platform, cache, cancel).await
406    }
407
408    /// Open an SSTable file for reading, sharing the provided
409    /// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache).
410    ///
411    /// Identical to [`open`](Self::open) except the reader stores `cache` (an
412    /// `Arc` clone) instead of minting its own, so every reader a manager opens
413    /// for one dataset consults the same bytes-bounded chunk cache (issue #1567).
414    pub async fn open_with_cache(
415        path: &Path,
416        config: &Config,
417        platform: Arc<Platform>,
418        cache: Arc<crate::storage::cache::DecompressedChunkCache>,
419    ) -> Result<Self> {
420        Self::open_with_cache_cancellable(
421            path,
422            config,
423            platform,
424            cache,
425            crate::storage::scan_cancel::ScanCancel::default(),
426        )
427        .await
428    }
429
430    /// Cancel-aware [`open_with_cache`](Self::open_with_cache) (issue #2383 fix C):
431    /// same as it but threads `cancel` into the Index.db parse. See
432    /// [`open_cancellable`](Self::open_cancellable).
433    pub async fn open_with_cache_cancellable(
434        path: &Path,
435        config: &Config,
436        platform: Arc<Platform>,
437        cache: Arc<crate::storage::cache::DecompressedChunkCache>,
438        cancel: crate::storage::scan_cancel::ScanCancel,
439    ) -> Result<Self> {
440        use crate::observability::{self as obs, catalog};
441        use tracing::Instrument as _;
442
443        let span = tracing::debug_span!(
444            "sstable.reader.open",
445            file_size = tracing::field::Empty,
446            sstable_format = tracing::field::Empty,
447        );
448
449        // Instrument the future rather than holding an entered guard across the
450        // `.await`: entering a span guard and then awaiting can attach unrelated
451        // async work scheduled on this task to the span. `Instrument` enters the
452        // span only while this specific future is polled.
453        let result = Self::open_inner(path, config, platform, cache, cancel)
454            .instrument(span.clone())
455            .await;
456        match &result {
457            Ok(reader) => {
458                let format = reader.sstable_format_label();
459                span.record("file_size", reader.stats.file_size);
460                span.record("sstable_format", format);
461                // SSTABLES_OPEN is a snapshot gauge of the live reader count;
462                // record the current PER-FORMAT count after this open succeeds so
463                // the format-attributed gauge series stays correct under mixed
464                // BIG/BTI readers.
465                let now = sstables_open_count_for(format).fetch_add(1, Ordering::Relaxed) + 1;
466                obs::record_gauge(
467                    catalog::SSTABLES_OPEN,
468                    now,
469                    &[(catalog::attr::SSTABLE_FORMAT, format.into())],
470                );
471            }
472            Err(e) => {
473                // Record the error WHILE the open span is current so
474                // `mark_span_error` marks THIS `sstable.reader.open` span. The
475                // instrumented future has already completed here, so the span is
476                // no longer entered; `in_scope` re-enters it for the duration of
477                // the error-recording call.
478                span.in_scope(|| obs::record_error(e, "reader"));
479            }
480        }
481        result
482    }
483
484    /// Open implementation; see [`open`](Self::open) for the instrumented wrapper.
485    async fn open_inner(
486        path: &Path,
487        config: &Config,
488        platform: Arc<Platform>,
489        chunk_cache: Arc<crate::storage::cache::DecompressedChunkCache>,
490        cancel: crate::storage::scan_cancel::ScanCancel,
491    ) -> Result<Self> {
492        // Retain the open-time Config before any local `config` shadowing so
493        // `perform_integrity_check` can delegate to `verify::verify_sstable`
494        // (single source of truth, issue #1283) under the same config.
495        let open_config = config.clone();
496        // #1249 (spec R1): reject below-floor versions BEFORE any file I/O.
497        // Gates derive solely from the filename, so this is the earliest point
498        // that can enforce the na+ floor — a pre-`na` (BIG) or non-`da` (BTI)
499        // descriptor is rejected with a typed `UnsupportedVersion` before we
500        // open/mmap/read the body (it never surfaces an I/O/parse error). Only a
501        // structurally-unparseable descriptor falls back to nb-compatible BIG
502        // gates (preserving existing tolerance for odd-but-5.0 unit-test paths);
503        // the gates do not change parsing decisions until VG3 flips behaviour.
504        let version_gates = Arc::new(match VersionGates::from_path(path) {
505            Ok(gates) => gates,
506            // A parsed-but-below-floor version is FATAL — never degrade it.
507            Err(e @ Error::UnsupportedVersion { .. }) => return Err(e),
508            Err(e) => {
509                tracing::debug!(
510                    "SSTableReader::open: could not derive VersionGates from {:?} ({}); \
511                     defaulting to nb-compatible BIG gates",
512                    path,
513                    e
514                );
515                VersionGates::Big(BigVersionGates::nb_fallback())
516            }
517        });
518
519        // Resolve the disk-access backend (buffered / mmap / direct, or auto)
520        // from config + environment overrides. See `Config::storage.disk_access_mode`.
521        let mut reader_config = SSTableReaderConfig::default();
522        reader_config.use_mmap = config.storage.use_mmap || mmap_enabled_via_env();
523        reader_config.mmap_min_size_bytes = config.storage.mmap_min_size_bytes;
524
525        let file_size = tokio::fs::metadata(path).await?.len();
526
527        // The explicit mode comes from env > config. The legacy `use_mmap` flag
528        // is folded in by promoting an otherwise-`Buffered` request to `Mmap` so
529        // the old opt-in keeps working (`Auto` and explicit non-buffered modes
530        // are left untouched, so a real backend decision always wins).
531        let configured_mode = disk_access_mode_via_env().unwrap_or(config.storage.disk_access_mode);
532        let configured_mode =
533            if reader_config.use_mmap && matches!(configured_mode, DiskAccessMode::Buffered) {
534                DiskAccessMode::Mmap
535            } else {
536                configured_mode
537            };
538
539        let resolved_mode = resolve_disk_access_mode(
540            configured_mode,
541            file_size,
542            reader_config.mmap_min_size_bytes as u64,
543            config.storage.direct_io_memory_fraction,
544            system_memory_bytes(),
545            direct_io_available(),
546        );
547
548        // Build both the shared point-read source and the per-scan factory from
549        // the same backend decision so concurrent scans get independent cursors
550        // (issue #815). `ScanSource::Mapped` shares the same `Arc<Mmap>` so no
551        // extra mapping is created per scan. Every non-buffered backend degrades
552        // gracefully to buffered I/O if the OS/filesystem refuses it.
553        let prefetch = prefetch_mode_via_env().unwrap_or(config.storage.prefetch);
554        let (source, scan_source) = Self::build_block_sources(
555            path,
556            file_size,
557            resolved_mode,
558            prefetch,
559            config.storage.direct_io_prefetch_bytes,
560        )
561        .await?;
562        let file = Arc::new(Mutex::new(source));
563
564        // Build the POINT-READ positional source ONCE at open (issue #1573, C2).
565        // It shares the reader's `Arc<Mmap>` when the backend is mmap (no extra
566        // mapping / fd); for buffered/direct it holds one dedicated read-only fd,
567        // which is exactly the "open the fd once, positioned-read thereafter"
568        // contract that removes the BTI per-lookup `open(2)`. Every non-mmap
569        // backend degrades gracefully to a plain positioned fd if the faster
570        // backend is refused, mirroring `build_block_sources`.
571        let point_source: Arc<dyn read_at::ReadAt> = match &scan_source {
572            ScanSource::Mapped(mmap) => {
573                #[cfg(unix)]
574                let point_mmap =
575                    Self::point_read_mmap(path, file_size, mmap, POINT_MMAP_MADV_RANDOM_MIN_BYTES);
576                #[cfg(not(unix))]
577                let point_mmap = mmap.clone();
578                Arc::new(read_at::MmapReadAt::new(point_mmap))
579            }
580            #[cfg(unix)]
581            ScanSource::Direct { .. } => match read_at::DirectReadAt::open(path, file_size) {
582                Ok(d) => Arc::new(d) as Arc<dyn read_at::ReadAt>,
583                Err(e) => {
584                    tracing::warn!(
585                        "Direct-I/O point source for {} failed ({}); using buffered pread",
586                        path.display(),
587                        e
588                    );
589                    Arc::new(read_at::PlainFileReadAt::open(path, file_size)?)
590                }
591            },
592            ScanSource::Buffered { .. } => {
593                Arc::new(read_at::PlainFileReadAt::open(path, file_size)?)
594            }
595        };
596
597        // Parse header - read available bytes, not a fixed size
598        // NOTE: For NB format files (Cassandra 4.x+), Data.db often contains compressed row data
599        // with no embedded header. The header.rs module detects this via filename pattern and
600        // returns a minimal header loaded from Statistics.db instead.
601        let header_size = std::cmp::min(4096, file_size as usize);
602        let mut header_buffer = vec![0u8; header_size];
603        {
604            let mut file_guard = file.lock().await;
605            let bytes_read = file_guard.read(&mut header_buffer).await?;
606            header_buffer.truncate(bytes_read);
607        }
608
609        // VG5 / Issue #831 / #909: BTI ("da") read support.
610        //
611        // BTI SSTables use a Partitions.db trie + Rows.db row index instead of
612        // Index.db/Summary.db. We load BOTH (tiny) tries fully into memory here so
613        // the point-lookup path (`lookup_partition_via_bti_trie` /
614        // `bti_point_lookup`) can walk them for O(log n) partition resolution:
615        //   - Partitions.db resolves a partition key to either a direct Data.db
616        //     offset (NARROW partition) or a positive RowsOffset (WIDE partition).
617        //   - Rows.db, indexed by that RowsOffset, recovers the wide partition's
618        //     Data.db position (issue #909/#910).
619        //
620        // Loading Partitions.db + Rows.db is the ONLY BTI-specific step in open():
621        // the rest of the flow (header / compression / Statistics-driven schema)
622        // tolerates the absent Index.db/Summary.db gracefully (those loaders
623        // return None).
624        let (bti_partitions_db, bti_rows_db) = if matches!(*version_gates, VersionGates::Bti(_)) {
625            let base = extract_sstable_base_name(path).ok_or_else(|| {
626                Error::unsupported_format(format!(
627                    "BTI (da) SSTable '{}' has a non-standard filename; cannot derive the \
628                         sibling Partitions.db name required for trie point lookup (#831).",
629                    path.display()
630                ))
631            })?;
632            let parent = path.parent().unwrap_or_else(|| Path::new("."));
633            let partitions_path = parent.join(format!("{}-Partitions.db", base));
634            let partitions_bytes = tokio::fs::read(&partitions_path).await.map_err(|e| {
635                Error::unsupported_format(format!(
636                    "BTI (da) SSTable '{}' is missing its sibling Partitions.db trie \
637                         (expected '{}'): {}. BTI read support requires Partitions.db for \
638                         partition-key point lookup (#831).",
639                    path.display(),
640                    partitions_path.display(),
641                    e
642                ))
643            })?;
644
645            // Rows.db carries the within-partition row index for WIDE
646            // partitions (issue #909/#910). It is ALWAYS emitted for a BTI
647            // SSTable (possibly 0 bytes for a narrow-only table), so a missing
648            // Rows.db is a structural error. A 0-byte file is valid: no
649            // partition resolved to a positive RowsOffset, so the point-lookup
650            // path never indexes into it.
651            let rows_path = parent.join(format!("{}-Rows.db", base));
652            let rows_bytes = tokio::fs::read(&rows_path).await.map_err(|e| {
653                Error::unsupported_format(format!(
654                    "BTI (da) SSTable '{}' is missing its sibling Rows.db row-index trie \
655                         (expected '{}'): {}. BTI read support requires Rows.db to resolve \
656                         wide-partition point lookups (#909/#910).",
657                    path.display(),
658                    rows_path.display(),
659                    e
660                ))
661            })?;
662
663            (Some(Arc::new(partitions_bytes)), Some(Arc::new(rows_bytes)))
664        } else {
665            let none: Option<Arc<Vec<u8>>> = None;
666            (none.clone(), none)
667        };
668
669        use tracing::Instrument;
670
671        let config = crate::cql::config::ParserConfig::default();
672        let parser = SSTableParser::new(config)?;
673        // Parse the header using enhanced version detection - strict error propagation.
674        // VersionGates are passed so VG3 can flip version-sensitive parsing decisions
675        // inside header parsing without re-deriving gates from the filename.
676        let header = parse_header_with_version_detection(&header_buffer, path, &version_gates)
677            .instrument(tracing::debug_span!("sstable.reader.open.header_parse"))
678            .await
679            .map_err(|e| {
680                Error::corruption(format!(
681                    "Failed to parse SSTable header for file '{}': {}. This indicates either \
682                     file corruption or an unsupported SSTable format. File size: {} bytes, \
683                     header buffer size: {} bytes.",
684                    path.display(),
685                    e,
686                    file_size,
687                    header_buffer.len()
688                ))
689            })?;
690        let header_size = calculate_actual_header_size(&header, &header_buffer)?;
691
692        // Schema extraction deferred until after Statistics.db columns are loaded (Issue #163)
693        // See schema extraction code after statistics_reader loading below
694
695        // Seek to start of data section
696        {
697            let mut file_guard = file.lock().await;
698            file_guard
699                .seek(std::io::SeekFrom::Start(header_size as u64))
700                .await?;
701        }
702
703        // Load CompressionInfo.db ONCE for chunked decompression (if it exists).
704        // This is the single authoritative parse per open (issue #1597 / G1); the
705        // component name is derived deterministically from `SsTableDescriptor`
706        // (one `exists()` probe, not the legacy ~25-generation scan).
707        let compression_info = Self::load_compression_info_metadata(path, &platform).await?;
708
709        // Derive the compression reader (algorithm only) from that single parsed
710        // result — no second parse. `algorithm_enum()` is fallible only for a name
711        // `parse()` would already have rejected, so no `unwrap`/`expect` is needed.
712        let compression_reader = match &compression_info {
713            Some(info) => Some(CompressionReader::new(info.algorithm_enum()?)),
714            None => None,
715        };
716
717        // Load CRC.db per-chunk checksums for uncompressed BIG read-time integrity
718        // (issue #1396). Only for uncompressed tables (compression_info None);
719        // compressed tables carry inline per-chunk CRCs instead.
720        let crc_reader = if compression_info.is_none() {
721            Self::load_crc_reader(path, &header, file_size).await?
722        } else {
723            None
724        };
725
726        // Pre-validate component architecture for better error handling
727        let components = Self::detect_component_files(path).await?;
728        if !components.is_empty() {
729            let integrity_issues = Self::validate_component_integrity(path, &components).await?;
730            if !integrity_issues.is_empty() {
731                tracing::warn!(
732                    "Component integrity issues detected but proceeding with loading: {:?}",
733                    integrity_issues
734                );
735            }
736        }
737
738        // Integrated in-Data.db index only; separate Index.db is parsed once by load_index_reader (#2385/#2395).
739        let index = Self::load_index(&file, &header)
740            .instrument(tracing::debug_span!("sstable.reader.open.load_index"))
741            .await?;
742
743        // Load bloom filter if available (supports both integrated and component-based)
744        let bloom_filter = Self::load_bloom_filter(&file, &header, &platform, path)
745            .instrument(tracing::debug_span!("sstable.reader.open.load_bloom"))
746            .await?;
747
748        // Issue #2412: load Summary.db FIRST so its usability (present, parsed, at
749        // least one sample entry — the authority a bounded lazy walk needs) can
750        // gate whether `load_index_reader` defers the Index.db parse (lazy, design
751        // §A) or falls back to today's eager parse (§A1's counted FellBack).
752        let summary_reader = Self::load_summary_reader(path, &platform)
753            .instrument(tracing::debug_span!("sstable.reader.open.load_summary"))
754            .await;
755        let summary_usable = summary_reader
756            .as_ref()
757            .map(|s| !s.get_entries().is_empty())
758            .unwrap_or(false);
759
760        // Load spec readers for enhanced metadata and lookups. Distinguish an absent
761        // Index.db from a present-but-unloadable one (issue #2302) so the full
762        // enumeration can WARN loud on the latter instead of silently full-scanning.
763        let (index_reader, index_present_but_unloadable) =
764            match Self::load_index_reader(path, &platform, &cancel, summary_usable)
765                .instrument(tracing::debug_span!(
766                    "sstable.reader.open.load_index_reader"
767                ))
768                .await?
769            {
770                component_loading::IndexLoadOutcome::Loaded(reader) => (Some(*reader), false),
771                component_loading::IndexLoadOutcome::Absent => (None, false),
772                component_loading::IndexLoadOutcome::PresentButUnloadable => (None, true),
773            };
774        let statistics_reader = Self::load_statistics_reader(path, &platform)
775            .instrument(tracing::debug_span!("sstable.reader.open.load_statistics"))
776            .await?;
777
778        // Extract SerializationHeader columns from Statistics.db (Issue #163)
779        // This enables schema extraction for V5CompressedLegacy format
780        let mut header = header; // Make mutable to populate columns
781        if let Some(ref stats_reader) = statistics_reader {
782            let statistics = stats_reader.statistics();
783            let partition_columns = &statistics.serialization_header_partition_keys;
784            let clustering_columns = &statistics.serialization_header_clustering_keys;
785            let regular_columns = &statistics.serialization_header_columns;
786
787            if !partition_columns.is_empty()
788                || !clustering_columns.is_empty()
789                || !regular_columns.is_empty()
790            {
791                tracing::debug!(
792                    "Populating header columns from Statistics.db SerializationHeader: {} partition keys, {} clustering keys, {} regular columns",
793                    partition_columns.len(),
794                    clustering_columns.len(),
795                    regular_columns.len()
796                );
797
798                let mut merged_columns = Vec::with_capacity(
799                    partition_columns.len() + clustering_columns.len() + regular_columns.len(),
800                );
801                merged_columns.extend_from_slice(partition_columns);
802                merged_columns.extend_from_slice(clustering_columns);
803                merged_columns.extend_from_slice(regular_columns);
804
805                header.columns = merged_columns;
806            }
807        }
808
809        // Extract schema from header for V5.0+ formats (after columns are populated)
810        let schema = if matches!(
811            header.cassandra_version,
812            CassandraVersion::V5_0NewBig
813                | CassandraVersion::V5_0Bti
814                | CassandraVersion::V5_0DataFormat
815                | CassandraVersion::V5_0FormatC
816                | CassandraVersion::V5_0FormatD
817                | CassandraVersion::V5_0FormatE
818                | CassandraVersion::V5_0FormatF
819                | CassandraVersion::V5_0FormatG
820        ) {
821            match TableSchema::from_sstable_header(&header) {
822                Ok(s) => {
823                    tracing::debug!(
824                        "Extracted schema from SSTable header: {}.{} ({} columns, {} partition keys, {} clustering keys)",
825                        s.keyspace,
826                        s.table,
827                        s.columns.len(),
828                        s.partition_keys.len(),
829                        s.clustering_keys.len()
830                    );
831                    Some(Arc::new(s))
832                }
833                Err(e) => {
834                    tracing::warn!(
835                        "Failed to extract schema from SSTable header for {}: {}. Schema-aware parsing will not be available.",
836                        path.display(),
837                        e
838                    );
839                    None
840                }
841            }
842        } else {
843            // Legacy formats don't have schema in header
844            None
845        };
846
847        // Derive block_count from CompressionInfo.db when available — this is the
848        // authoritative source for compressed SSTables (no-heuristics mandate #28).
849        // Each entry in chunk_offsets corresponds to one compressed block in Data.db.
850        let block_count = compression_info
851            .as_ref()
852            .map(|ci| ci.chunk_offsets.len() as u64)
853            .unwrap_or(0);
854
855        let stats = SSTableReaderStats {
856            file_size,
857            entry_count: header.stats.row_count,
858            table_count: 1, // Will be updated as we discover tables
859            block_count,
860            index_size: 0,        // Will be updated if index is loaded
861            bloom_filter_size: 0, // Will be updated if bloom filter is loaded
862            compression_ratio: header.stats.compression_ratio,
863            cache_hit_rate: 0.0,
864        };
865
866        // Extract generation from filename or use default
867        let generation = extract_generation_from_path(path);
868
869        // Stable per-reader cache identity (issue #1567): hash the immutable
870        // file path + generation. Authoritative reader identity, never byte
871        // content — combined with a per-site salt to form the cache key.
872        let chunk_cache_id = {
873            use std::hash::{Hash, Hasher};
874            let mut h = std::collections::hash_map::DefaultHasher::new();
875            path.hash(&mut h);
876            generation.hash(&mut h);
877            h.finish()
878        };
879
880        // Global key→partition-offset cache handle (issue #2059): the process-global
881        // shared instance when block caching is enabled, else a per-reader disabled
882        // no-op. Built from the open-time config BEFORE `open_config` is moved.
883        let key_offset_cache = super::build_key_offset_cache(&open_config);
884
885        // This reader's authoritative inode-stable generation identity (issue #2059),
886        // the namespacing half of every global key-cache entry. Resolved ONCE here
887        // from the Data.db path + parsed generation, and stored immutably — it stays
888        // stable across a #2383 rebind (a path swap over a byte-identical generation),
889        // so cached locations survive a rebind. `None` on a stat failure → the cache
890        // is bypassed rather than fabricating an identity (no-heuristics #28).
891        let generation_identity =
892            crate::storage::cache::GenerationIdentity::resolve(path, generation);
893
894        // Cache the immutable `[first_key, last_key]` endpoint tokens ONCE at open
895        // (issue #1576, Epic C/C5 perf finding) so `partition_key_out_of_range`
896        // only hashes the QUERY key on the hot point-read path instead of
897        // re-hashing the two fixed endpoints on every read. Armed only when
898        // Summary.db is present with two non-empty endpoints — the exact condition
899        // the short-circuit itself requires. Uses the same Cassandra Murmur3 token
900        // as the hot path, so the cached values are byte-identical.
901        let endpoint_tokens = summary_reader.as_ref().and_then(|s| {
902            let first = s.get_first_key();
903            let last = s.get_last_key();
904            (!first.is_empty() && !last.is_empty()).then(|| {
905                (
906                    crate::util::cassandra_murmur3::cassandra_murmur3_token(first),
907                    crate::util::cassandra_murmur3::cassandra_murmur3_token(last),
908                )
909            })
910        });
911
912        Ok(Self {
913            file_path: arc_swap::ArcSwap::from_pointee(path.to_path_buf()),
914            file,
915            scan_source,
916            point_source,
917            header,
918            parser,
919            index,
920            bloom_filter,
921            compression_reader,
922            config: reader_config,
923            open_config,
924            platform,
925            stats,
926            #[cfg(feature = "tombstones")]
927            tombstone_merger: TombstoneMerger::new(),
928            generation,
929            actual_header_size: header_size,
930            index_reader,
931            index_present_but_unloadable,
932            summary_reader,
933            endpoint_tokens,
934            statistics_reader,
935            schema_registry: None, // Will be set by set_schema_registry() after construction
936            schema,
937            #[cfg(feature = "state_machine")]
938            registry_schema: None, // Resolved by resolve_registry_schema() at wiring time (#1692)
939            udt_registry: None, // Will be set when available for UDT-aware parsing
940            scan_cancel: crate::storage::scan_cancel::ScanCancel::default(), // #2264: set via set_scan_cancel
941            compression_info: compression_info.map(Arc::new),
942            crc_reader,
943            verified_uncompressed_chunks: std::sync::Mutex::new(std::collections::HashSet::new()),
944            version_gates,
945            bti_partitions_db,
946            bti_rows_db,
947            chunk_cache,
948            chunk_cache_id,
949            bti_lookup_memo: std::sync::Mutex::new(None),
950            key_offset_cache,
951            generation_identity,
952        })
953    }
954
955    /// Whether this reader's block source is backed by a memory map.
956    ///
957    /// Test-only hook used to verify that the `use_mmap` config / env wiring
958    /// actually selects the intended backend end-to-end.
959    #[cfg(test)]
960    pub(crate) async fn is_mmap_backed(&self) -> bool {
961        self.file.lock().await.is_mmap()
962    }
963
964    /// Clone the reader's shared positional point source (issue #1573 convoy
965    /// scenario). Test-only: lets a test wrap the real source in a slow/serializing
966    /// decorator, then reinstall it via [`set_point_source`](Self::set_point_source).
967    #[cfg(test)]
968    pub(crate) fn clone_point_source(&self) -> Arc<dyn read_at::ReadAt> {
969        self.point_source.clone()
970    }
971
972    /// Replace the reader's point source (issue #1573 convoy scenario). Test-only;
973    /// requires `&mut self`, so it must be called BEFORE the reader is shared
974    /// behind an `Arc` across the concurrent point reads under test.
975    #[cfg(test)]
976    pub(crate) fn set_point_source(&mut self, src: Arc<dyn read_at::ReadAt>) {
977        self.point_source = src;
978    }
979
980    /// Whether this reader's block source is backed by direct I/O.
981    ///
982    /// Test-only hook used to verify the `disk_access_mode` config / env wiring
983    /// selects the direct-I/O backend (issue: directio prefetch config).
984    #[cfg(all(test, unix))]
985    pub(crate) async fn is_direct_backed(&self) -> bool {
986        self.file.lock().await.is_direct()
987    }
988
989    /// Open a buffered point-read source and its per-scan factory. Shared by the
990    /// buffered backend and as the graceful fallback for mmap/direct.
991    async fn open_buffered_sources(
992        path: &Path,
993        file_size: u64,
994    ) -> Result<(BlockSource, ScanSource)> {
995        // A5 read-work counter (FILE_OPENS; consumer C2): one per open(2) that mints
996        // a reader fd — here the buffered cold-open / graceful-fallback site. No-op
997        // in release (design.md Decision 1/2).
998        crate::storage::sstable::read_work_counters::record_file_open();
999        Ok((
1000            BlockSource::buffered_sized(File::open(path).await?, file_size),
1001            ScanSource::Buffered {
1002                file_len: file_size,
1003            },
1004        ))
1005    }
1006
1007    /// Build the shared point-read [`BlockSource`] and the per-scan
1008    /// [`ScanSource`] for a resolved [`DiskAccessMode`].
1009    ///
1010    /// `prefetch` selects read-ahead advice for mmap and (together with
1011    /// `prefetch_bytes`) the direct-I/O read-ahead window. Each non-buffered
1012    /// backend degrades gracefully to buffered I/O if the OS or filesystem
1013    /// refuses it (mmap can fail on network mounts; direct I/O is unsupported on
1014    /// some platforms/filesystems), so opening never fails purely because a
1015    /// faster backend is unavailable.
1016    async fn build_block_sources(
1017        path: &Path,
1018        file_size: u64,
1019        mode: DiskAccessMode,
1020        prefetch: PrefetchMode,
1021        prefetch_bytes: usize,
1022    ) -> Result<(BlockSource, ScanSource)> {
1023        // With prefetch off, use the minimal aligned read-ahead the direct
1024        // backend still requires (a single block, rounded up inside the cursor).
1025        // Otherwise clamp the configured window so a sub-chunk
1026        // `direct_io_prefetch_bytes` cannot make one compression chunk straddle
1027        // many aligned refills (issue #1596, F6). The actual per-SSTable chunk
1028        // length is not parsed yet at this open-time construction site, so the
1029        // clamp floors against Cassandra's default 64 KiB chunk — which fully
1030        // protects the default case (and the 1 MiB default window is already
1031        // above the floor, so the clamp is a no-op there).
1032        let direct_window = if matches!(prefetch, PrefetchMode::Off) {
1033            1
1034        } else {
1035            prefetch_window::clamp_direct_prefetch_window(
1036                prefetch_bytes,
1037                prefetch_window::DEFAULT_COMPRESSION_CHUNK_BYTES,
1038            )
1039        };
1040        match mode {
1041            DiskAccessMode::Buffered => Self::open_buffered_sources(path, file_size).await,
1042            DiskAccessMode::Mmap => match Self::map_file(path) {
1043                Ok(mmap) => {
1044                    tracing::debug!(
1045                        "Opened {} via memory map ({} bytes)",
1046                        path.display(),
1047                        file_size
1048                    );
1049                    // Best-effort read-ahead advice (Unix-only; madvise has no
1050                    // Windows equivalent here). Failure is non-fatal.
1051                    #[cfg(unix)]
1052                    if let Some(advice) = mmap_advice_for(prefetch) {
1053                        if let Err(e) = mmap.advise(advice) {
1054                            tracing::debug!(
1055                                "madvise({:?}) on {} failed: {}",
1056                                advice,
1057                                path.display(),
1058                                e
1059                            );
1060                        }
1061                    }
1062                    let mmap = Arc::new(mmap);
1063                    Ok((BlockSource::mapped(mmap.clone()), ScanSource::Mapped(mmap)))
1064                }
1065                Err(e) => {
1066                    tracing::warn!(
1067                        "Memory-mapping {} failed ({}); falling back to buffered I/O",
1068                        path.display(),
1069                        e
1070                    );
1071                    Self::open_buffered_sources(path, file_size).await
1072                }
1073            },
1074            DiskAccessMode::Direct => {
1075                #[cfg(unix)]
1076                {
1077                    match source::DirectCursor::open(path, direct_window) {
1078                        Ok(cursor) => {
1079                            tracing::debug!(
1080                                "Opened {} via direct I/O ({} bytes, {}-byte window)",
1081                                path.display(),
1082                                file_size,
1083                                direct_window
1084                            );
1085                            Ok((
1086                                BlockSource::direct(cursor),
1087                                ScanSource::Direct {
1088                                    window: direct_window,
1089                                    file_len: file_size,
1090                                },
1091                            ))
1092                        }
1093                        Err(e) => {
1094                            tracing::warn!(
1095                                "Direct I/O on {} failed ({}); falling back to buffered I/O",
1096                                path.display(),
1097                                e
1098                            );
1099                            Self::open_buffered_sources(path, file_size).await
1100                        }
1101                    }
1102                }
1103                #[cfg(not(unix))]
1104                {
1105                    let _ = direct_window;
1106                    tracing::warn!(
1107                        "Direct I/O is unavailable on this platform; using buffered I/O for {}",
1108                        path.display()
1109                    );
1110                    Self::open_buffered_sources(path, file_size).await
1111                }
1112            }
1113            // `resolve_disk_access_mode` never yields `Auto`; handle defensively.
1114            DiskAccessMode::Auto => Self::open_buffered_sources(path, file_size).await,
1115        }
1116    }
1117
1118    /// Memory-map an SSTable file read-only.
1119    ///
1120    /// # Safety / correctness
1121    ///
1122    /// The returned [`Mmap`](memmap2::Mmap) aliases the file's bytes for its
1123    /// entire lifetime. SSTables are immutable once written, and CQLite treats
1124    /// them as read-only inputs, so this matches Cassandra's own mmap read
1125    /// strategy. Mutating the underlying file while a reader is open is
1126    /// undefined behaviour — callers must not do so.
1127    ///
1128    /// Note that only the initial mapping is fallible here. Once mapped, a later
1129    /// page fault — caused by truncation, deletion, or a network/overlay
1130    /// filesystem hiccup — raises `SIGBUS` and **cannot** be recovered as an
1131    /// `io::Error`. This is why mmap is opt-in and gated on immutable local
1132    /// files; see [`Config`]'s `storage.use_mmap` for the full constraints.
1133    fn map_file(path: &Path) -> Result<memmap2::Mmap> {
1134        // A5 read-work counter (FILE_OPENS; consumer C2): one per open(2) that mints
1135        // a reader fd — here the mmap cold-open site. No-op in release (design.md
1136        // Decision 1/2).
1137        crate::storage::sstable::read_work_counters::record_file_open();
1138        let std_file = std::fs::File::open(path)?;
1139        // SAFETY: read-only mapping of a file assumed immutable for the
1140        // reader's lifetime; see the function-level note above.
1141        let mmap = unsafe { memmap2::MmapOptions::new().map(&std_file)? };
1142        Ok(mmap)
1143    }
1144
1145    /// Choose the mmap the point-read source will use. For a large file
1146    /// (`file_size >= min_random_bytes`) map a SECOND, dedicated read-only mapping
1147    /// of the same file and advise it `MADV_RANDOM`, returning that distinct
1148    /// mapping so scattered point faults read one page instead of the ~128 KiB
1149    /// read-ahead window (issue #2210). The returned mapping is a SEPARATE
1150    /// allocation from `scan_mmap`, which is left unadvised — advising the point map
1151    /// therefore cannot affect the scan map (#1143 preserved). Below the threshold,
1152    /// or if the dedicated map / its advice fails, share `scan_mmap` unchanged
1153    /// (never keep a redundant unadvised 2nd map). Mapped directly (not via
1154    /// `map_file`) so the read-work FILE_OPENS counter is untouched.
1155    #[cfg(unix)]
1156    fn point_read_mmap(
1157        path: &Path,
1158        file_size: u64,
1159        scan_mmap: &Arc<memmap2::Mmap>,
1160        min_random_bytes: u64,
1161    ) -> Arc<memmap2::Mmap> {
1162        if file_size >= min_random_bytes {
1163            if let Ok(std_file) = std::fs::File::open(path) {
1164                // SAFETY: read-only mapping of a file assumed immutable for the
1165                // reader's lifetime (same contract as `map_file`).
1166                match unsafe { memmap2::MmapOptions::new().map(&std_file) } {
1167                    Ok(point_mmap) => match point_mmap.advise(memmap2::Advice::Random) {
1168                        Ok(()) => {
1169                            tracing::debug!(
1170                                "Dedicated MADV_RANDOM point-read mapping for {} ({} bytes, #2210)",
1171                                path.display(),
1172                                file_size
1173                            );
1174                            return Arc::new(point_mmap);
1175                        }
1176                        Err(e) => tracing::debug!(
1177                            "madvise(RANDOM) on dedicated point map for {} failed ({}); \
1178                             sharing scan mapping",
1179                            path.display(),
1180                            e
1181                        ),
1182                    },
1183                    Err(e) => tracing::debug!(
1184                        "Dedicated point map for {} failed ({}); sharing scan mapping",
1185                        path.display(),
1186                        e
1187                    ),
1188                }
1189            }
1190        }
1191        scan_mmap.clone()
1192    }
1193
1194    /// Load CompressionInfo.db metadata for chunked reading
1195    async fn load_compression_info_metadata(
1196        path: &Path,
1197        _platform: &Arc<Platform>,
1198    ) -> Result<Option<CompressionInfo>> {
1199        use tokio::fs::File;
1200        use tokio::io::AsyncReadExt;
1201
1202        // Try to find CompressionInfo.db in same directory.
1203        let parent_dir = path.parent().unwrap_or(Path::new("."));
1204        // Derive the base name via the descriptor parser, which handles
1205        // hyphenated-UUID ids (e.g. "da-00000000-0000-0000-0000-000000000001-bti").
1206        // A fixed parts[0..3] split mangles those and looks for the wrong
1207        // "*-CompressionInfo.db", silently treating compressed data as
1208        // uncompressed (roborev #970).
1209        let base_name = crate::storage::sstable::version_gate::SsTableDescriptor::parse(path)
1210            .ok()
1211            .map(|d| format!("{}-{}-{}", d.version, d.sstable_id, d.format.as_str()));
1212
1213        if let Some(base) = base_name {
1214            let compression_info_path = parent_dir.join(format!("{}-CompressionInfo.db", base));
1215            if compression_info_path.exists() {
1216                let mut file = File::open(&compression_info_path).await?;
1217                let mut buffer = Vec::new();
1218                file.read_to_end(&mut buffer).await?;
1219
1220                // Fail-fast (issue #1001): a CompressionInfo.db that is present but
1221                // names an unknown/unsupported compressor (or is otherwise malformed)
1222                // MUST hard-error at reader-open time, BEFORE any Data.db chunk is read —
1223                // never silently fall back to the uncompressed path. The error carries
1224                // the offending component path for diagnosis. A genuinely uncompressed
1225                // SSTable has no CompressionInfo.db at all and returns Ok(None) below.
1226                let info = CompressionInfo::parse(&buffer).map_err(|e| {
1227                    Error::UnsupportedFormat(format!(
1228                        "Failed to parse CompressionInfo.db at {}: {}",
1229                        compression_info_path.display(),
1230                        e
1231                    ))
1232                })?;
1233                tracing::debug!(
1234                    "Loaded CompressionInfo: algorithm={}, chunk_length={}, chunks={}",
1235                    info.algorithm,
1236                    info.chunk_length,
1237                    info.chunk_offsets.len()
1238                );
1239                return Ok(Some(info));
1240            }
1241        }
1242
1243        Ok(None)
1244    }
1245
1246    /// Load the `CRC.db` per-chunk checksum sidecar for read-time integrity of
1247    /// **uncompressed** BIG SSTables (issue #1396).
1248    ///
1249    /// Called only when `compression_info` is `None` (compressed tables carry
1250    /// inline per-chunk CRCs and no `CRC.db`). Returns:
1251    /// - `Some(crc)` for an uncompressed BIG SSTable that ships a `CRC.db`
1252    ///   (Cassandra writes one for every uncompressed BIG table) — verification is
1253    ///   then default-on for every uncompressed chunk read.
1254    /// - `None` for BTI (`da`) tables (Cassandra emits no `CRC.db`) or an
1255    ///   uncompressed BIG table whose `CRC.db` is absent. The absent case is the
1256    ///   owner-pinned **warn-and-proceed** decision (design D4): a `tracing::warn!` is
1257    ///   emitted so the missing integrity component is visible, and the read
1258    ///   proceeds unverified rather than hard-failing.
1259    ///
1260    /// A present-but-malformed `CRC.db` is a hard [`Error`] at open time (never a
1261    /// silent fall-through to unverified reads), mirroring the CompressionInfo.db
1262    /// fail-fast posture (#1001).
1263    async fn load_crc_reader(
1264        path: &Path,
1265        header: &SSTableHeader,
1266        data_len: u64,
1267    ) -> Result<Option<Arc<crc::CrcDb>>> {
1268        // BTI (`da`) never ships a CRC.db; nothing to load.
1269        if matches!(header.cassandra_version, CassandraVersion::V5_0Bti) {
1270            return Ok(None);
1271        }
1272
1273        let parent_dir = path.parent().unwrap_or(Path::new("."));
1274        let Some(base) = compression::extract_sstable_base_name(path) else {
1275            return Ok(None);
1276        };
1277        let crc_path = parent_dir.join(format!("{}-CRC.db", base));
1278        if !crc_path.exists() {
1279            // Absent CRC.db on an uncompressed BIG SSTable: warn-and-proceed
1280            // (owner-pinned, design D4). Cassandra 5.0 writes a CRC.db for every
1281            // uncompressed BIG SSTable, so its absence is notable but not fatal —
1282            // reads proceed unverified rather than hard-failing.
1283            tracing::warn!(
1284                "CRC.db absent for uncompressed SSTable {} — proceeding without \
1285                 read-time per-chunk CRC verification (warn-and-proceed, issue #1396)",
1286                path.display()
1287            );
1288            return Ok(None);
1289        }
1290
1291        let crc = crc::CrcDb::open(&crc_path, data_len).await.map_err(|e| {
1292            Error::corruption(format!(
1293                "Failed to parse CRC.db at {}: {}",
1294                crc_path.display(),
1295                e
1296            ))
1297        })?;
1298        tracing::debug!(
1299            "Loaded CRC.db for {}: chunk_size={}, chunks={}",
1300            path.display(),
1301            crc.chunk_size(),
1302            crc.chunk_count()
1303        );
1304        Ok(Some(Arc::new(crc)))
1305    }
1306
1307    /// Set the schema registry for schema-driven operations.
1308    ///
1309    /// This is a SYNC method (`&mut self`, non-async), so it CANNOT await the
1310    /// async registry to pre-resolve the sync schema-fallback cache
1311    /// ([`registry_schema`](Self::registry_schema)) — doing so would require a
1312    /// `block_on`, which #1692 (AG3) forbids on a tokio worker thread. It
1313    /// therefore only stores the registry and INVALIDATES any previously
1314    /// pre-resolved cache (the new registry may resolve a different schema).
1315    ///
1316    /// Direct callers that intend to trigger a SYNC parse (whose schema-fallback
1317    /// tier reads `registry_schema`) must, from an async context, either call the
1318    /// combined [`attach_schema_registry`](Self::attach_schema_registry) instead,
1319    /// or call [`resolve_registry_schema`](Self::resolve_registry_schema) after
1320    /// this. Otherwise the sync path deliberately falls through to
1321    /// header-column construction (or `None`). The crate's own async wiring path
1322    /// (`open_reader_with_schema`) already does this.
1323    #[cfg(feature = "state_machine")]
1324    #[deprecated(
1325        note = "attaches the registry but CANNOT pre-resolve the sync schema-fallback cache \
1326                (would need a forbidden block_on, issue #1692); registry schemas will not be \
1327                available to a subsequent sync `get_table_schema`. Use the async \
1328                `attach_schema_registry` (which pre-resolves), or call `resolve_registry_schema` \
1329                after this from an async context, for registry-schema-aware reads."
1330    )]
1331    pub fn set_schema_registry(
1332        &mut self,
1333        schema_registry: Arc<tokio::sync::RwLock<crate::schema::SchemaRegistry>>,
1334    ) {
1335        self.schema_registry = Some(schema_registry);
1336        // Invalidate the sync fallback cache: a prior registry (if any) may have
1337        // resolved a schema that no longer applies. It is re-populated only by an
1338        // explicit `resolve_registry_schema()` / `attach_schema_registry()`.
1339        self.registry_schema = None;
1340        tracing::debug!(
1341            "Schema registry set for {}.{} - enabling schema-driven digest computation",
1342            self.header.keyspace,
1343            self.header.table_name
1344        );
1345    }
1346
1347    /// Attach a schema registry AND pre-resolve it into the sync fallback cache
1348    /// (issue #1692). Async wiring convenience for DIRECT callers: it combines
1349    /// [`set_schema_registry`](Self::set_schema_registry) with
1350    /// [`resolve_registry_schema`](Self::resolve_registry_schema) so the sync
1351    /// `get_table_schema` fallback tier is populated without any `block_on`.
1352    ///
1353    /// Prefer this over the bare sync `set_schema_registry` whenever you attach a
1354    /// registry to a reader you will parse synchronously.
1355    #[cfg(feature = "state_machine")]
1356    pub async fn attach_schema_registry(
1357        &mut self,
1358        schema_registry: Arc<tokio::sync::RwLock<crate::schema::SchemaRegistry>>,
1359    ) {
1360        // Intentional internal use of the sync attach step; we immediately
1361        // pre-resolve below, which is exactly what the deprecation directs.
1362        #[allow(deprecated)]
1363        self.set_schema_registry(schema_registry);
1364        self.resolve_registry_schema().await;
1365    }
1366
1367    /// Set the schema registry for schema-driven operations (non-state_machine builds)
1368    #[cfg(not(feature = "state_machine"))]
1369    pub fn set_schema_registry(&mut self, schema_registry: Arc<crate::schema::SchemaRegistry>) {
1370        self.schema_registry = Some(schema_registry);
1371        tracing::debug!(
1372            "Schema registry set for {}.{} - enabling schema-driven digest computation",
1373            self.header.keyspace,
1374            self.header.table_name
1375        );
1376    }
1377
1378    /// Set the UDT registry for UDT-aware parsing in collections
1379    ///
1380    /// This enables proper parsing of UDTs inside collections (List, Set, Map)
1381    /// by providing the UDT field definitions needed for nested type resolution.
1382    pub fn set_udt_registry(&mut self, registry: crate::schema::UdtRegistry) {
1383        self.udt_registry = Some(registry);
1384        tracing::debug!(
1385            "UDT registry set for {}.{} - enabling UDT-aware collection parsing",
1386            self.header.keyspace,
1387            self.header.table_name
1388        );
1389    }
1390
1391    /// Whether a UDT registry has been wired onto this reader (issue #2310, WS1
1392    /// #2345). A warm-handle cache that hands SHARED `Arc<SSTableReader>`s to the
1393    /// reader-based k-way merge seam (`KWayMerger::new_from_readers`, which takes
1394    /// NO `udt_registry` parameter) MUST open each reader WITH its registry
1395    /// already resolved before wrapping it in `Arc`; this getter lets that caller
1396    /// PROVE the registry is present rather than silently decoding a frozen/nested
1397    /// UDT cell as `Blob` (the #1234 data-loss class).
1398    pub fn has_udt_registry(&self) -> bool {
1399        self.udt_registry.is_some()
1400    }
1401
1402    /// The `Data.db` path this reader was opened from (issue #2310). The warm
1403    /// registry keys parsed state on the file's inode-stable generation identity,
1404    /// so it needs the backing path to `stat` device+inode without re-listing the
1405    /// directory.
1406    pub fn file_path(&self) -> PathBuf {
1407        // Owned clone (cheap relative to a scan/point-read): the path is now
1408        // interior-mutable to support [`Self::rebind_path`] (#2383), so we cannot
1409        // hand out a borrow into the `ArcSwap`.
1410        self.file_path.load().as_ref().clone()
1411    }
1412
1413    /// The `Data.db` size in bytes captured at open. Used by the warm registry's
1414    /// authoritative rebind identity check (issue #2383): a rebind is accepted
1415    /// only when the live candidate's `(device, inode)` AND size match this
1416    /// reader's, else it fails closed to a full re-open.
1417    pub fn file_size(&self) -> u64 {
1418        self.stats.file_size
1419    }
1420
1421    /// Rebind this reader's lazy-scan path to `new_path` WITHOUT re-opening or
1422    /// re-parsing (issue #2383, the #2356 "rebind-by-inode" direction).
1423    ///
1424    /// The caller MUST have already established that `new_path` is the SAME
1425    /// on-disk generation as this reader by AUTHORITATIVE identity — matching
1426    /// `(device, inode)` and size (issue #28 no-heuristics; Cassandra snapshot
1427    /// files are hardlinks to the immutable SSTable, so a same-inode candidate is
1428    /// byte-identical). The already-open point/scan handles reference the shared
1429    /// inode and stay valid across the swap; only [`Self::new_scan_cursor`]'s
1430    /// per-scan `File::open` reads this path, so pointing it at a LIVE hardlink
1431    /// restores the #2352 ENOENT protection with zero parse cost.
1432    ///
1433    /// ## In-flight isolation invariant (roborev-1654 HIGH, adjudicated)
1434    ///
1435    /// Mutating this shared `Arc<SSTableReader>`'s path while a concurrent request
1436    /// scans it does NOT break the in-flight request's isolation, because the
1437    /// rebind is byte-transparent and monotone-toward-live:
1438    /// 1. **Same immutable inode.** The sole caller (`warm::registry::rebind`)
1439    ///    fires only after `rebuild::rebind_matches` proves
1440    ///    `(device, inode, generation)` + size equality; every rebind target is a
1441    ///    hardlink to the SAME immutable SSTable inode, so any path this reader
1442    ///    carries yields byte-identical data (issue #28 no-heuristics).
1443    /// 2. **Atomic swap.** `file_path` is an `arc_swap::ArcSwap<PathBuf>`; a
1444    ///    concurrent `file_path()` sees either the old or the new whole `PathBuf`,
1445    ///    never a torn value.
1446    /// 3. **Dead → live only.** A rebind happens only when the current path is
1447    ///    dead and an identity-matching LIVE hardlink exists, so an in-flight
1448    ///    request's NEXT `new_scan_cursor` `File::open` is strictly MORE likely to
1449    ///    succeed after the rebind than before (pre-rebind it would ENOENT).
1450    /// 4. **No semantics off the path.** No `file_path()` consumer re-derives
1451    ///    keyspace/table/schema from the path after `open`; the path is used ONLY
1452    ///    for `File::open` (bytes) and all parsed read state (header, compression
1453    ///    info, CRC, index, generation) lives on `&self`, fixed at open — the
1454    ///    swapped path changes which hardlink is read, never what the bytes mean.
1455    ///    This covers BOTH the `Data.db` path AND the lazy `Index.db` path (below):
1456    ///    both are same-inode hardlink siblings in the rebound snapshot dir.
1457    ///
1458    /// ## Rebinding the lazy `Index.db` path too (issue #2356 roborev)
1459    ///
1460    /// Repointing ONLY the `Data.db` path reintroduced the #2352 ENOENT class for
1461    /// the dominant point-read shape: a BIG reader opened lazily over a usable
1462    /// `Summary.db` (#2412 §A) keeps its own `Index.db` path, and every DEFERRED
1463    /// `Index.db` open (`ensure_materialized`, the Summary-guided bounded-interval
1464    /// point probe) reads THAT path. If the original snapshot dir is torn down
1465    /// before the reader ever materialized, a not-yet-materialized reader would
1466    /// `File::open` the dead path and ENOENT. So the rebind ALSO repoints the
1467    /// `IndexReader`'s path to the `Index.db` hardlink sibling of `new_path`,
1468    /// keeping every `Index.db` consumer (deferred-materialize, point-interval, and
1469    /// the streaming walk's `current_index_db_path` Data.db-sibling derivation) on
1470    /// the live generation.
1471    pub fn rebind_path(&self, new_path: &Path) {
1472        self.file_path.store(Arc::new(new_path.to_path_buf()));
1473        // Follow the rebind for the lazy Index.db path too: derive the Index.db
1474        // hardlink sibling of the new Data.db path and repoint the IndexReader, so a
1475        // deferred materialize / point-interval read opens the live file, not the
1476        // dead open-time snapshot path (issue #2356 roborev, #2352 class).
1477        if let Some(index_reader) = self.index_reader.as_ref() {
1478            if let Some(index_sibling) = index_db_sibling(new_path) {
1479                index_reader.rebind_path(&index_sibling);
1480            }
1481        }
1482    }
1483
1484    /// The immutable `[first_key_token, last_key_token]` endpoints cached at open
1485    /// (issue #1576), or `None` when `Summary.db` was absent/empty. The Flight
1486    /// warm registry (issue #2310) uses this to token-prune a WARM reader set with
1487    /// ZERO extra I/O — a warm hit re-reads no `Summary.db`, preserving the
1488    /// "zero Index/Summary/Statistics/bloom parse" property even for a
1489    /// token-filtered scan. SSTables store partitions in token order, so the
1490    /// first endpoint is the min token and the last the max.
1491    pub fn endpoint_tokens(&self) -> Option<(i64, i64)> {
1492        self.endpoint_tokens
1493    }
1494
1495    /// Whether this reader's `Index.db` partition map is CURRENTLY fully
1496    /// resident (issue #2412 §D — the Flight warm registry's memory accounting).
1497    ///
1498    /// A BIG reader opened lazily over a usable `Summary.db` (design §A) reports
1499    /// `false` until some consumer's [`ensure_materialized`]-driven full parse
1500    /// (a full/compaction scan whose Summary-guided streaming walk `FellBack`)
1501    /// actually happens; the Summary-guided point/scan paths (§B/§C) never
1502    /// trigger it. An eagerly-opened reader (the `Summary.db`-absent FellBack
1503    /// case, §A1) reports `true` immediately — its `Index.db` was fully parsed
1504    /// at open. No `Index.db` at all (absent component) reports `true` (there is
1505    /// no resident cost to represent either way).
1506    ///
1507    /// [`ensure_materialized`]: crate::storage::sstable::index_reader::IndexReader
1508    pub fn index_is_materialized(&self) -> bool {
1509        self.index_reader
1510            .as_ref()
1511            .map(|ir| ir.is_materialized())
1512            .unwrap_or(true)
1513    }
1514
1515    /// Wire a cooperative-cancellation token into this reader's long-running
1516    /// scans (issue #2264).
1517    ///
1518    /// The compaction streaming read and its sequential-scan fallback poll this
1519    /// token at a bounded interval, so a cancelled Flight `do_get` abandons an
1520    /// otherwise uninterruptible full-Data.db walk within milliseconds instead of
1521    /// waiting out the coarse ~1–2 min transport backstop. Idempotent; the last
1522    /// token set wins. Readers never wired keep the default never-cancel flag.
1523    pub fn set_scan_cancel(&mut self, cancel: crate::storage::scan_cancel::ScanCancel) {
1524        self.scan_cancel = cancel;
1525    }
1526
1527    /// Get reader statistics
1528    pub async fn stats(&self) -> Result<&SSTableReaderStats> {
1529        Ok(&self.stats)
1530    }
1531
1532    /// Close the reader and release resources
1533    pub async fn close(self) -> Result<()> {
1534        debug!("Closing SSTable reader for {:?}", self.file_path());
1535        // File will be closed automatically when dropped. The shared B1
1536        // decompressed-chunk cache is reference-counted and outlives one reader
1537        // (issue #1568: the per-reader block cache that was cleared here is gone).
1538        Ok(())
1539    }
1540
1541    /// Calculate header size based on format and actual header content
1542    pub fn calculate_header_size(&self) -> usize {
1543        self.actual_header_size
1544    }
1545
1546    /// Get the Cassandra version from the SSTable header
1547    pub fn cassandra_version(&self) -> CassandraVersion {
1548        self.header.cassandra_version
1549    }
1550
1551    /// Low-cardinality on-disk format family label for telemetry attributes
1552    /// (`"bti"` or `"big"`). Derived from the authoritative [`VersionGates`], so
1553    /// it stays bounded to exactly two values — safe to attach to metrics. NOT a
1554    /// substitute for [`format_version`](Self::format_version), which returns the
1555    /// exact on-disk version token.
1556    pub(crate) fn sstable_format_label(&self) -> &'static str {
1557        match *self.version_gates {
1558            VersionGates::Bti(_) => "bti",
1559            VersionGates::Big(_) => "big",
1560        }
1561    }
1562
1563    /// Whether the on-disk `DeletionTime` uses the Cassandra 5.0 UNSIGNED
1564    /// `localDeletionTime` serializer (`oa`/`da`, `hasUIntDeletionTime`) rather
1565    /// than the legacy SIGNED `i32` form (`nb`).
1566    ///
1567    /// The verifier uses this to interpret a raw partition-level
1568    /// `localDeletionTime`: on the legacy signed form a NEGATIVE value (that is
1569    /// not the `i32::MAX` live sentinel) is unambiguously corrupt, whereas on the
1570    /// unsigned form a value in `[2^31, 2^32)` is a legitimate far-future
1571    /// deletion time and must NOT be flagged (no heuristics — the format decides).
1572    ///
1573    /// Authority: `BigFormat.java:409` (`hasUIntDeletionTime`), `DeletionTime.java`.
1574    pub fn has_uint_deletion_time(&self) -> bool {
1575        match *self.version_gates {
1576            VersionGates::Big(ref g) => g.has_uint_deletion_time,
1577            VersionGates::Bti(ref g) => g.has_uint_deletion_time,
1578        }
1579    }
1580
1581    /// Get the SSTable format version string
1582    pub fn format_version(&self) -> Result<String> {
1583        let file_path = self.file_path();
1584        let filename = file_path
1585            .file_name()
1586            .and_then(|f| f.to_str())
1587            .ok_or_else(|| {
1588                Error::InvalidPath(format!("Invalid SSTable filename: {:?}", file_path))
1589            })?;
1590
1591        let parts: Vec<&str> = filename.split('-').collect();
1592        if parts.is_empty() {
1593            return Err(Error::InvalidFormat(format!(
1594                "Cannot extract format version from filename: {}",
1595                filename
1596            )));
1597        }
1598
1599        Ok(parts[0].to_string())
1600    }
1601
1602    /// Get a reference to the SSTable header
1603    pub fn header(&self) -> &SSTableHeader {
1604        &self.header
1605    }
1606
1607    /// Get the table schema extracted from the SSTable header
1608    ///
1609    /// Returns `None` for legacy formats or if schema extraction failed.
1610    pub fn schema(&self) -> Option<&TableSchema> {
1611        self.schema.as_deref()
1612    }
1613
1614    /// The effective table schema the read/verify paths decode with — the same
1615    /// four-tier resolution `partition_clustering_verify_scan` uses (issue #1282).
1616    ///
1617    /// Returned owned because the registry-fallback tier constructs a schema; the
1618    /// verifier needs it to drive the authoritative clustering comparator
1619    /// ([`crate::storage::write_engine::mutation::ClusteringKey::compare`]).
1620    pub fn effective_schema(&self) -> Option<TableSchema> {
1621        self.get_table_schema(None)
1622    }
1623
1624    /// Extract write time from entry metadata
1625    pub fn extract_write_time_from_entry(&self, _key: &RowKey, row: &ScanRow) -> i64 {
1626        use tracing::warn;
1627
1628        match row {
1629            ScanRow::Marker(Value::Tombstone(info)) => info.deletion_time,
1630            _ => std::time::SystemTime::now()
1631                .duration_since(std::time::UNIX_EPOCH)
1632                .map(|d| d.as_micros() as i64)
1633                .unwrap_or_else(|e| {
1634                    warn!("Failed to get system time: {}; using fallback value 0", e);
1635                    0
1636                }),
1637        }
1638    }
1639}
1640
1641impl Drop for SSTableReader {
1642    fn drop(&mut self) {
1643        use crate::observability::{self as obs, catalog};
1644        // Keep the per-format live-reader count and the SSTABLES_OPEN gauge in
1645        // sync as readers are released. Use a single atomic read-modify-write
1646        // (`fetch_sub`) on the SAME per-format counter `open()` incremented: a
1647        // load-then-store would race concurrent drops and lose decrements,
1648        // drifting the gauge permanently high. `open()` only ever increments on
1649        // success, so this never underflows in practice.
1650        let format = self.sstable_format_label();
1651        let now = sstables_open_count_for(format).fetch_sub(1, Ordering::Relaxed) - 1;
1652        obs::record_gauge(
1653            catalog::SSTABLES_OPEN,
1654            now,
1655            &[(catalog::attr::SSTABLE_FORMAT, format.into())],
1656        );
1657    }
1658}
1659
1660impl std::fmt::Debug for SSTableReader {
1661    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1662        f.debug_struct("SSTableReader")
1663            .field("file_path", &self.file_path())
1664            .field("header", &self.header)
1665            .field("has_index", &self.index.is_some())
1666            .field("has_bloom_filter", &self.bloom_filter.is_some())
1667            .field("compression", &self.header.compression.algorithm)
1668            .field("stats", &self.stats)
1669            .finish()
1670    }
1671}
1672
1673/// Helper function to create a reader with default configuration
1674pub async fn open_sstable_reader(
1675    path: &Path,
1676    config: &Config,
1677    platform: Arc<Platform>,
1678) -> Result<SSTableReader> {
1679    SSTableReader::open(path, config, platform).await
1680}