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}