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}