Skip to main content

cqlite_core/storage/sstable/
mod.rs

1//! SSTable (Sorted String Table) implementation
2
3pub mod bloom;
4pub mod bti;
5pub mod bulletproof_reader;
6pub mod chunk_decompressor;
7pub mod chunk_reader;
8pub mod compression;
9pub mod compression_info;
10pub mod directory;
11pub mod directory_integration_tests;
12pub mod format_detector;
13#[cfg(feature = "write-support")]
14mod generation_merge; // Cross-generation read reconciliation (issues #883/#885/#957/#1579).
15pub mod header_spec;
16pub mod index;
17pub mod index_reader;
18pub mod key_digest;
19pub mod performance_benchmarks;
20pub mod promoted_index_reader;
21pub mod read_work_counters;
22pub mod reader;
23pub mod summary_reader;
24pub mod version_gate;
25pub mod work_counters;
26/// Authoritative zstd frame-header parsing for dictionary detection (issue #1414).
27///
28/// Gated on the `zstd` feature: the only consumer is the zstd decode path in
29/// `chunk_decompressor`, which is itself `#[cfg(feature = "zstd")]`. Without this
30/// gate the module's items are unused under a zstd-off build (e.g. the
31/// `Minimal Compression Build` CI lane `--features=lz4,snappy`) and fail
32/// `-D warnings`.
33#[cfg(feature = "zstd")]
34pub mod zstd_frame;
35pub use reader::SSTableReader;
36/// Explicit directory refresh (issue #1749): re-scan + atomic diff-and-swap of
37/// the held reader set. Not `state_machine`-gated — meaningful for minimal builds.
38pub mod refresh;
39pub use refresh::RefreshReport;
40mod reverse_scan; // BIG reverse partition iteration (issue #1184); file is tombstones-gated.
41pub mod row_cell_state_machine;
42/// Cross-SSTable scan ordering: k-way merge in Cassandra token order (issue #1580).
43mod scan_merge;
44/// Authoritative snapshot-aware SSTable path parsing (issue #2384): the single
45/// source of truth for deriving `{keyspace}.{table}` identity from a Data.db path,
46/// transparently resolving Cassandra `snapshots/{tag}` layouts.
47pub(crate) mod snapshot_path;
48pub mod statistics_reader;
49pub mod stream_merge_probe; // Multi-generation streaming-merge resident-rows probe (issue #1579, D3).
50#[cfg(feature = "tombstones")]
51pub mod tombstone_merger;
52pub mod validation;
53pub mod verify; // Verifier contract for compressed + corrupted SSTables (epic #970, issue #1000).
54pub use verify::{
55    verify_sstable, verify_sstable_generation, VerifyErrorClass, VerifyFinding, VerifyMode,
56    VerifyReport,
57};
58
59// M5: SSTable writer components (Issue #359)
60#[cfg(feature = "write-support")]
61pub mod writer;
62
63// Test modules
64/// F1: scan must not hold the table_readers read guard across its I/O (Issue #1591).
65#[cfg(test)]
66mod issue_1591_scan_lock_test;
67/// F4: the fan-out scan merge must not deadlock under admission when it fans out
68/// to more generations than the admission cap (Issue #1594).
69#[cfg(all(test, feature = "scan-offload-probe"))]
70mod issue_1594_fanout_deadlock_test;
71/// VG1: Thread VersionGates through the read path (Issue #653).
72#[cfg(test)]
73mod issue_653_version_gates_plumbing_test;
74#[cfg(test)]
75mod key_digest_integration_test;
76#[cfg(test)]
77mod key_digest_test;
78#[cfg(all(test, feature = "experimental"))]
79mod oa_format_compliance_test;
80#[cfg(all(test, feature = "state_machine"))]
81mod row_cell_state_machine_test;
82/// S3 verification tests for Index.db/Summary.db/BTI (epic #622, issue #625).
83#[cfg(test)]
84mod s3_verification_test;
85/// S4 verification tests for Statistics.db/CompressionInfo.db/Filter.db (epic #622, issue #626).
86#[cfg(test)]
87mod s4_verification_test;
88
89use std::collections::HashMap;
90use std::path::{Path, PathBuf};
91use std::sync::Arc;
92use tokio::sync::{Mutex, RwLock};
93
94#[cfg(feature = "tombstones")]
95use self::tombstone_merger::{EntryMetadata, GenerationValue, TombstoneMerger};
96use crate::platform::Platform;
97use crate::types::CellWriteMetadata;
98#[cfg(not(feature = "tombstones"))] // #1917 concat fallbacks; tombstones uses k-way merge
99use crate::util::cassandra_murmur3::cmp_partition_keys_by_token;
100use crate::{types::TableId, Config, Result, RowKey, ScanRow};
101
102/// Maximum directory depth when scanning for SSTable files.
103///
104/// Writer creates `data_dir/keyspace/table/nb-{gen}-big-*.db` (2 levels deep),
105/// so 3 levels provides a safety margin.
106pub(crate) const MAX_SSTABLE_SCAN_DEPTH: usize = 3;
107
108/// The AUTHORITATIVE partition-key shape of a table, derived schema-less from the
109/// SSTable Statistics.db SerializationHeader (issue #1750).
110///
111/// Carries ONLY facts Cassandra actually serialises: how many partition-key
112/// components and clustering keys the table has, and the REAL names of the
113/// non-key (regular + static) columns. The partition-/clustering-key column NAMES
114/// are deliberately absent — Cassandra never serialises them, so any pk-name a
115/// schema-less parser reports is a synthesised placeholder that must not drive
116/// routing decisions (no-heuristics mandate #28).
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct PartitionKeyShape {
119    /// Number of partition-key components (a single-component pk is `1`).
120    pub partition_key_count: usize,
121    /// Number of clustering-key columns.
122    pub clustering_key_count: usize,
123    /// The real names of the non-key (regular + static) columns.
124    pub non_key_column_names: std::collections::HashSet<String>,
125    /// For a single-component pk ONLY: the sole partition-key column's authoritative
126    /// CQL type (from the SerializationHeader `keyType`) and its header-carried name.
127    ///
128    /// The CQL type is authoritative — Cassandra serialises partition-key TYPES even
129    /// though it never serialises their NAMES (issue #1750, roborev 3784). It drives
130    /// the TYPED single-component key encoding so a schema-less `WHERE int_pk = 42`
131    /// builds the 4-byte `int` key Cassandra wrote, not an 8-byte `BigInt` key. The
132    /// `name` is the header's synthesised placeholder (`id` for uuid/timeuuid, else
133    /// `partition_key`) — NOT a trusted identity, only the label under which
134    /// `build_row_from_scan` reconstructs the pk column so the seeked row can be
135    /// re-validated against the predicate. `None` for a composite pk (the typed
136    /// single-component fast path does not apply) or when no reader exposed a
137    /// single-component pk type.
138    pub single_pk_component: Option<PartitionKeyComponent>,
139}
140
141/// The authoritative type + header-synthesised name of a single-component
142/// partition key (issue #1750, roborev 3784). See [`PartitionKeyShape`].
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct PartitionKeyComponent {
145    /// The header's synthesised pk column name (`id` / `partition_key`). NOT a
146    /// trusted identity — only a label for pk-column reconstruction.
147    pub name: String,
148    /// The authoritative CQL type of the pk component (from `keyType`).
149    pub cql_type: String,
150}
151
152/// Resolve the AUTHORITATIVE [`PartitionKeyShape`] by UNIONing the non-key column
153/// names across EVERY reader's SerializationHeader while requiring CONSISTENT key
154/// metadata (issue #1750, roborev 3786).
155///
156/// This is the pure core of [`SSTableManager::partition_key_shape`], factored out so
157/// the multi-reader union + consistency logic is unit-testable without SSTable
158/// binaries. `headers` is one `&[ColumnInfo]` per resolved reader for the table (an
159/// empty or non-header reader contributes nothing).
160///
161/// A multi-generation / schema-evolved table can add a REGULAR column in a LATER
162/// generation absent from an earlier generation's header. Using only the first
163/// header would omit that column from the non-key name set, so a
164/// `WHERE <later-gen-regular-col> = <val>` could be MISCLASSIFIED as a partition-key
165/// seek. To prevent that, the non-key names are UNIONed across all headers, and the
166/// partition-key count, clustering-key count, and single-component pk type+name must
167/// agree across every header that has partition-key columns.
168///
169/// The classifier proves a column IS the partition key by its ABSENCE from the unioned
170/// non-key set — which is sound ONLY if the union is COMPLETE, i.e. every reader's
171/// columns were visible. So this is fail-safe on incomplete metadata (roborev 3788):
172///
173/// Returns `None` when
174/// - there are no readers (fail-safe: caller full-scans), OR
175/// - ANY resolved reader lacks a usable partition-key shape — an empty/missing
176///   SerializationHeader, or a populated header exposing no partition-key column
177///   (we cannot see that reader's regular columns, so the union is incomplete and a
178///   column could be misclassified as the pk by absence), OR
179/// - the headers' key metadata is INCONSISTENT (pk-count / clustering-count / single-pk
180///   type+name disagreement — should never happen for one table, but if it did we
181///   cannot trust the shape).
182///
183/// `Some(shape)` is returned only when EVERY reader contributed a usable, consistent
184/// pk shape. All facts come from SerializationHeaders — no heuristics (#28).
185fn partition_key_shape_from_headers<'a>(
186    headers: impl IntoIterator<Item = &'a [crate::parser::header::ColumnInfo]>,
187) -> Option<PartitionKeyShape> {
188    // Key metadata the first header-bearing reader established; every subsequent
189    // header MUST match it or the whole shape is declined (inconsistent → None).
190    struct AgreedKeyShape {
191        partition_key_count: usize,
192        clustering_key_count: usize,
193        single_pk_component: Option<PartitionKeyComponent>,
194    }
195    let mut agreed: Option<AgreedKeyShape> = None;
196    let mut non_key_column_names = std::collections::HashSet::new();
197
198    for columns in headers {
199        // COMPLETE-UNION fail-safe (roborev 3788): the classifier proves a column is
200        // the partition key by its ABSENCE from `non_key_column_names`. That proof is
201        // only sound if we can see EVERY reader's columns. A reader with no usable
202        // SerializationHeader (empty columns) would silently drop its regular columns
203        // from the union, so a regular column present ONLY in that headerless reader
204        // could be misclassified as the pk. Decline the whole shape → the caller
205        // full-scans (always correct). No heuristics (#28).
206        if columns.is_empty() {
207            return None;
208        }
209        let mut partition_key_count = 0usize;
210        let mut clustering_key_count = 0usize;
211        // The sole partition-key component's authoritative CQL type + header-
212        // synthesised name (issue #1750, roborev 3784). Recorded for the FIRST pk
213        // column seen in THIS header; only surfaced when the pk is single-component.
214        // Cassandra serialises pk TYPES (authoritative) but not NAMES (`name` is a
215        // placeholder used only for pk reconstruction).
216        let mut first_pk_component: Option<PartitionKeyComponent> = None;
217        for col in columns {
218            match (col.is_primary_key, col.is_clustering) {
219                (true, false) => {
220                    if first_pk_component.is_none() {
221                        first_pk_component = Some(PartitionKeyComponent {
222                            name: col.name.clone(),
223                            cql_type: col.column_type.clone(),
224                        });
225                    }
226                    partition_key_count += 1;
227                }
228                (true, true) => clustering_key_count += 1,
229                (false, _) => {
230                    // Union non-key names across ALL readers (roborev 3786): a
231                    // regular column present only in a later generation must be
232                    // recognised, else it could be misread as the partition key.
233                    non_key_column_names.insert(col.name.clone());
234                }
235            }
236        }
237        // A SerializationHeader always names at least one partition-key component; a
238        // header that populated columns yet exposes NO partition-key column is not a
239        // usable pk shape. Under the complete-union fail-safe (roborev 3788) we cannot
240        // trust the union when any reader's pk shape is unknown, so decline → the
241        // caller full-scans rather than record a bogus all-zero shape.
242        if partition_key_count == 0 {
243            return None;
244        }
245        // The typed single-component pk type is authoritative ONLY for a single-
246        // component key; drop it for a composite pk (that fast path does not apply
247        // and a composite key isn't one raw value).
248        let single_pk_component = (partition_key_count == 1)
249            .then_some(first_pk_component)
250            .flatten();
251
252        match &agreed {
253            None => {
254                agreed = Some(AgreedKeyShape {
255                    partition_key_count,
256                    clustering_key_count,
257                    single_pk_component,
258                });
259            }
260            Some(prev) => {
261                // Require CONSISTENT key metadata across readers; any disagreement
262                // means we cannot trust the shape → decline (#28).
263                if prev.partition_key_count != partition_key_count
264                    || prev.clustering_key_count != clustering_key_count
265                    || prev.single_pk_component != single_pk_component
266                {
267                    return None;
268                }
269            }
270        }
271    }
272
273    agreed.map(|shape| PartitionKeyShape {
274        partition_key_count: shape.partition_key_count,
275        clustering_key_count: shape.clustering_key_count,
276        non_key_column_names,
277        single_pk_component: shape.single_pk_component,
278    })
279}
280
281/// SSTable file identifier
282#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
283pub struct SSTableId(pub String);
284
285impl Default for SSTableId {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291impl SSTableId {
292    /// Create a new SSTable ID with timestamp using Cassandra naming convention
293    pub fn new() -> Self {
294        let timestamp = std::time::SystemTime::now()
295            .duration_since(std::time::UNIX_EPOCH)
296            .unwrap()
297            .as_micros();
298        // Use Cassandra naming convention: <keyspace>-<table>-<generation>-<format>-Data.db
299        // For generated files, we'll use a simplified pattern: sstable-<timestamp>-big-Data.db
300        Self(format!("sstable-{}-big-Data.db", timestamp))
301    }
302
303    /// Create SSTable ID from filename
304    pub fn from_filename(filename: &str) -> Self {
305        Self(filename.to_string())
306    }
307
308    /// Get the filename
309    pub fn filename(&self) -> &str {
310        &self.0
311    }
312}
313
314/// Extract table name from SSTable directory path.
315///
316/// SSTable files are stored in directories named `<table_name>-<uuid>`.
317/// For example: `simple_table-6aa08200a25111f0a3fef1a551383fb9/na-1-big-Data.db`
318///
319/// This function extracts the table name portion by:
320/// 1. Getting the parent directory name
321/// 2. Splitting on '-' and removing the UUID suffix
322///
323/// Removes the UUID suffix from directory names like:
324/// - `simple_table-6aa08200a25111f0a3fef1a551383fb9` → `simple_table`
325/// - `my-test-table-UUID` → `my-test-table`
326///
327/// Returns `None` if the path doesn't contain a valid directory component.
328///
329/// Note: Table names can contain hyphens, so we need to be careful to only remove the UUID.
330/// UUIDs in Cassandra directory names are 32 hex chars without hyphens (e.g., 6aa08200a25111f0a3fef1a551383fb9).
331///
332/// Snapshot-aware (issue #2384): routes through the authoritative
333/// [`snapshot_path`] parser so a snapshot read
334/// (`.../{table}-{uuid}/snapshots/{tag}/...-Data.db`) keys the `SSTableManager`
335/// under the REAL table name, never the snapshot tag.
336pub(crate) fn extract_table_name(sstable_path: &Path) -> Option<String> {
337    snapshot_path::extract_table_name(sstable_path)
338}
339
340/// Extract the fully-qualified table key (`"keyspace.table"`) from an SSTable path.
341///
342/// Cassandra on-disk layout is: `<data_dir>/<keyspace>/<table>-<uuid>/<sstable_files>`
343///
344/// This function walks up two directory levels from the SSTable file to identify both the
345/// table directory (`parent`) and keyspace directory (`grandparent`), producing a
346/// `"keyspace.table"` key that uniquely identifies a table across keyspaces.
347///
348/// When datasets-v3 added `test_oa.simple_table` alongside the existing
349/// `test_basic.simple_table`, using the unqualified name `"simple_table"` as the
350/// `table_readers` key caused both tables' SSTables to be registered under the same
351/// entry, returning combined rows for any query.  This function is the authoritative
352/// source of table identity for `SSTableManager` (Issue #680).
353///
354/// # Returns
355///
356/// `Some((keyspace, table_name))` when both directory levels can be extracted;
357/// `None` if the path does not contain enough components (e.g., a flat test directory).
358///
359/// # Examples
360///
361/// ```text
362/// ".../sstables/test_basic/simple_table-6aa08200a25111f0a3fef1a551383fb9/nb-1-big-Data.db"
363///   → Some(("test_basic", "simple_table"))
364///
365/// ".../sstables/test_oa/simple_table-4b7cd05064e711f1bd3ac7dbf655c673/oa-2-big-Data.db"
366///   → Some(("test_oa", "simple_table"))
367///
368/// "nb-1-big-Data.db"   (flat, no parent dirs)
369///   → None
370/// ```
371///
372/// Snapshot-aware (issue #2384): both the keyspace and the table name are derived
373/// through the authoritative [`snapshot_path`] parser, so a snapshot read resolves
374/// the REAL `{keyspace}.{table}` for `SSTableManager` keying rather than
375/// `snapshots`/`{tag}`.
376pub fn extract_keyspace_and_table_name(sstable_path: &Path) -> Option<(String, String)> {
377    let table_name = snapshot_path::extract_table_name(sstable_path)?;
378    let keyspace = snapshot_path::extract_keyspace(sstable_path)?;
379    Some((keyspace, table_name))
380}
381
382/// Return `true` if the filename is a macOS AppleDouble resource-fork sidecar.
383///
384/// macOS creates `._<name>` companion files when copying to non-Apple filesystems
385/// (HFS+→FAT32, SMB shares, CI artifact tarballs, etc.).  These are NOT valid
386/// Cassandra SSTable files even though they share the `-Data.db` suffix.
387///
388/// This predicate is the single point of truth for the `._*` filter; both
389/// `load_from_table_directories` and `find_data_files` call it so the guard can
390/// never silently diverge.  See Issue #481.
391#[inline]
392fn is_apple_double_sidecar(filename: &str) -> bool {
393    filename.starts_with("._")
394}
395
396/// Deterministic test gate that pauses a scan mid-flight so a test can observe
397/// whether the `table_readers` read guard is held across the scan's I/O (issue
398/// #1591). Armed per-manager (never process-globally) so parallel tests never
399/// interfere: only scans on the manager whose gate is armed ever wait. No
400/// wall-clock sleeps — the scan signals `reached` when it arrives and blocks on
401/// `release` until the test lets it proceed.
402#[cfg(test)]
403pub(crate) mod scan_gate {
404    use std::sync::Arc;
405    use tokio::sync::Notify;
406
407    /// Two one-shot rendezvous points shared between the paused scan and the test.
408    #[derive(Debug, Default)]
409    pub(crate) struct Gate {
410        /// Scan → test: "I have arrived at the gate."
411        pub reached: Notify,
412        /// Test → scan: "you may proceed."
413        pub release: Notify,
414    }
415
416    /// Called from inside `scan`'s per-reader loop. When a gate is armed on this
417    /// manager, signal arrival and block until the test releases it; otherwise a
418    /// no-op. Placed at the per-reader I/O so it sits INSIDE the read-guarded
419    /// region before the fix and OUTSIDE it after (the guard is dropped at the
420    /// snapshot boundary) — the exact seam the test asserts on.
421    pub(crate) async fn wait_if_armed(gate: &Option<Arc<Gate>>) {
422        if let Some(gate) = gate {
423            gate.reached.notify_one();
424            gate.release.notified().await;
425        }
426    }
427}
428
429/// SSTable manager that handles multiple SSTable files
430#[derive(Debug)]
431pub struct SSTableManager {
432    /// Base directory for SSTable files
433    base_path: PathBuf,
434
435    /// Active SSTable readers indexed by ID
436    readers: Arc<RwLock<HashMap<SSTableId, Arc<reader::SSTableReader>>>>,
437
438    /// Table name to SSTable readers mapping
439    /// Maps table names (e.g., "simple_table") to their corresponding SSTable readers
440    pub(crate) table_readers: Arc<RwLock<HashMap<String, Vec<Arc<reader::SSTableReader>>>>>,
441
442    /// Platform abstraction
443    platform: Arc<Platform>,
444
445    /// Configuration
446    config: Config,
447
448    /// How this manager discovers SSTable generations, recorded so
449    /// [`SSTableManager::refresh_tables`] re-runs the same discovery (issue #1749).
450    discovery_source: refresh::DiscoverySource,
451
452    /// Serializes concurrent [`SSTableManager::refresh_tables`] calls end-to-end
453    /// (discovery through swap) so two refreshes cannot interleave — a stale
454    /// discovery set from one refresh can never remove a generation a concurrent
455    /// refresh just added (TOCTOU, issue #1749, Decision 4). Held ONLY across a
456    /// refresh; queries never take this lock (they use the `RwLock` read guards).
457    refresh_lock: Arc<Mutex<()>>,
458
459    /// Schema registry for schema-aware operations (feature-gated)
460    #[cfg(feature = "state_machine")]
461    schema_registry: Arc<RwLock<Option<Arc<RwLock<crate::schema::SchemaRegistry>>>>>,
462
463    /// Shared, bytes-bounded decompressed-chunk cache (issue #1567, Epic B/B1),
464    /// sized once from `config.memory.block_cache.max_size` when
465    /// `config.memory.block_cache.enabled` is `true` (the default). Cloned into
466    /// every reader this manager opens (via `open_reader_with_schema` →
467    /// `SSTableReader::open_with_cache`) so all readers of one dataset — including
468    /// those added by a later `refresh_tables` — share one cache and one budget.
469    ///
470    /// When `block_cache.enabled == false` (issue #1568) this is a genuine no-op
471    /// [`disabled`](crate::storage::cache::DecompressedChunkCache::disabled) cache
472    /// so reads bypass caching entirely, and [`stats_chunk_cache`](Self::stats_chunk_cache)
473    /// reports `None` so the memory-stats surface reads a structural zero.
474    pub(crate) chunk_cache: Arc<crate::storage::cache::DecompressedChunkCache>,
475
476    /// Per-manager deterministic scan gate (issue #1591 test infrastructure).
477    /// `None` in production; a test arms it via [`arm_scan_gate`](Self::arm_scan_gate)
478    /// to pause this manager's scans mid-flight. Being per-manager (not a global)
479    /// keeps parallel tests isolated.
480    #[cfg(test)]
481    scan_gate: std::sync::Mutex<Option<Arc<scan_gate::Gate>>>,
482}
483
484/// Build the reader-facing decompressed-chunk cache honoring the advertised
485/// `config.memory.block_cache.enabled` toggle (issue #1568). When enabled (the
486/// default) the cache is sized from `block_cache.max_size`; when disabled it is a
487/// genuine no-op cache so reads bypass caching entirely rather than the toggle
488/// being decorative.
489fn build_chunk_cache(config: &Config) -> Arc<crate::storage::cache::DecompressedChunkCache> {
490    use crate::storage::cache::DecompressedChunkCache;
491    if config.memory.block_cache.enabled {
492        Arc::new(DecompressedChunkCache::with_budget_bytes(
493            config.memory.block_cache.max_size as usize,
494        ))
495    } else {
496        Arc::new(DecompressedChunkCache::disabled())
497    }
498}
499
500/// Resolve a reader's key→partition-offset cache handle (issue #2059), honoring the
501/// same `config.memory.block_cache.enabled` read-cache toggle B2 established
502/// (issue #1568).
503///
504/// When enabled (the default) this returns a shared `Arc` clone of the PROCESS-GLOBAL
505/// [`GlobalKeyOffsetCache`](crate::storage::cache::GlobalKeyOffsetCache) — ONE
506/// byte-bounded instance shared by every open reader, so the aggregate resident
507/// footprint is bounded by a single fixed global cap
508/// ([`DEFAULT_GLOBAL_KEY_CACHE_BYTES`](crate::storage::cache::DEFAULT_GLOBAL_KEY_CACHE_BYTES))
509/// REGARDLESS of open-reader count — never the retired per-reader form's
510/// `N_readers × per_reader_cap` (the unbounded-aggregate hazard the flight
511/// `WarmTableRegistry` reintroduced by pinning one reader per warm generation).
512/// Entries are namespaced by the reader's authoritative generation identity, so one
513/// global cache safely serves every generation. When disabled it returns a per-reader
514/// [`disabled`](crate::storage::cache::GlobalKeyOffsetCache::disabled) no-op so the
515/// point-read path bypasses key caching entirely rather than the toggle being
516/// decorative. The budget is a fixed named constant, not a new config knob.
517pub(crate) fn build_key_offset_cache(
518    config: &Config,
519) -> Arc<crate::storage::cache::GlobalKeyOffsetCache> {
520    use crate::storage::cache::GlobalKeyOffsetCache;
521    if config.memory.block_cache.enabled {
522        GlobalKeyOffsetCache::global()
523    } else {
524        Arc::new(GlobalKeyOffsetCache::disabled())
525    }
526}
527
528impl SSTableManager {
529    /// The shared chunk cache to expose to the memory-stats surface, or `None`
530    /// when block caching is disabled (issue #1568).
531    ///
532    /// Returns `Some` only when `config.memory.block_cache.enabled` is `true`; a
533    /// disabled cache is a no-op the stats shell must report as a structural zero
534    /// (via `MemoryManager::new`), never as an active cache.
535    pub(crate) fn stats_chunk_cache(
536        &self,
537    ) -> Option<Arc<crate::storage::cache::DecompressedChunkCache>> {
538        if self.config.memory.block_cache.enabled {
539            Some(Arc::clone(&self.chunk_cache))
540        } else {
541            None
542        }
543    }
544
545    /// Process-level aggregate of the per-reader key→partition-offset caches
546    /// (issue #1571, B5). Sums each reader's real observability counters (hits,
547    /// misses, evictions, resident bytes, capacity bytes) into one snapshot.
548    ///
549    /// Counts each distinct reader **exactly once** by unioning the by-id
550    /// `self.readers` map and the by-name `table_readers` map and deduping on
551    /// `Arc::as_ptr` (roborev #1571 Low). Deduping is required for correctness in
552    /// **both** directions: (a) the two maps re-reference the same reader `Arc`s,
553    /// so a naive sum of both would double-count; (b) crucially, `self.readers` is
554    /// **not** a strict superset of `table_readers` — `SSTableId::from_filename`
555    /// Snapshot of the PROCESS-GLOBAL key→partition-offset cache (issue #2059),
556    /// reported as the single consolidated envelope through
557    /// `Database::stats().memory_stats`.
558    ///
559    /// Since #2059 there is ONE global cache shared by every reader (not a per-reader
560    /// cache summed over the open set), so this reads that single instance's live
561    /// counters directly. When this manager's `block_cache.enabled == false` the read
562    /// caches are disabled, so this reports honest zeros (mirroring
563    /// [`stats_chunk_cache`](Self::stats_chunk_cache) returning `None`) rather than
564    /// surfacing another database's global activity. Every field is a real observed
565    /// value — no fabricated placeholders.
566    pub(crate) async fn aggregate_key_cache_stats(
567        &self,
568    ) -> crate::storage::cache::GlobalKeyCacheSnapshot {
569        if self.config.memory.block_cache.enabled {
570            crate::storage::cache::GlobalKeyOffsetCache::global().snapshot()
571        } else {
572            crate::storage::cache::GlobalKeyCacheSnapshot::default()
573        }
574    }
575
576    /// Create a new SSTable manager
577    pub async fn new(
578        path: &Path,
579        config: &Config,
580        platform: Arc<Platform>,
581        #[cfg(feature = "state_machine")] schema_registry: Option<
582            Arc<RwLock<crate::schema::SchemaRegistry>>,
583        >,
584    ) -> Result<Self> {
585        let base_path = path.to_path_buf();
586        let readers = Arc::new(RwLock::new(HashMap::new()));
587        let table_readers = Arc::new(RwLock::new(HashMap::new()));
588
589        let manager = Self {
590            base_path,
591            readers,
592            table_readers,
593            platform,
594            config: config.clone(),
595            discovery_source: refresh::DiscoverySource::BasePath,
596            refresh_lock: Arc::new(Mutex::new(())),
597            #[cfg(feature = "state_machine")]
598            schema_registry: Arc::new(RwLock::new(schema_registry)),
599            chunk_cache: build_chunk_cache(config),
600            #[cfg(test)]
601            scan_gate: std::sync::Mutex::new(None),
602        };
603
604        // Load existing SSTable files
605        manager.load_existing_sstables().await?;
606
607        Ok(manager)
608    }
609
610    /// Create a new SSTable manager from pre-discovered table directories
611    ///
612    /// This method accepts a list of table directory paths (from DiscoveryService)
613    /// and loads SSTables from those specific directories. It does not perform
614    /// filesystem scanning beyond the provided directories - this avoids duplicate
615    /// scanning when integrating with the discovery/engine lifecycle.
616    ///
617    /// Use this method when you have pre-discovered table directories and want
618    /// to avoid redundant filesystem scanning. Use `new()` when you want automatic
619    /// discovery from a single base directory.
620    ///
621    /// # Arguments
622    ///
623    /// * `storage_path` - Base storage path (used for context, not for scanning)
624    /// * `table_dirs` - List of table directory paths from DiscoveryService
625    ///   (e.g., `/data/keyspace1/table1-abc123`)
626    /// * `config` - Configuration
627    /// * `platform` - Platform abstraction
628    ///
629    /// # Returns
630    ///
631    /// A new `SSTableManager` with SSTables loaded from the specified directories
632    ///
633    /// # Errors
634    ///
635    /// Returns an error if any of the specified directories cannot be read.
636    /// Individual SSTable loading errors are logged but do not fail the entire operation.
637    ///
638    /// # Example
639    ///
640    /// ```no_run
641    /// use cqlite_core::storage::sstable::SSTableManager;
642    /// use cqlite_core::{Config, Platform};
643    /// use std::sync::Arc;
644    /// use std::path::PathBuf;
645    ///
646    /// # async fn example() -> cqlite_core::Result<()> {
647    /// let config = Config::default();
648    /// let platform = Arc::new(Platform::new(&config).await?);
649    ///
650    /// // Get table directories from DiscoveryService
651    /// let table_dirs = vec![
652    ///     PathBuf::from("/data/keyspace1/table1-abc123"),
653    ///     PathBuf::from("/data/keyspace1/table2-def456"),
654    /// ];
655    ///
656    /// let manager = SSTableManager::new_from_discovered_paths(
657    ///     &PathBuf::from("/data"),
658    ///     table_dirs,
659    ///     &config,
660    ///     platform,
661    ///     #[cfg(feature = "state_machine")]
662    ///     None,
663    /// ).await?;
664    /// # Ok(())
665    /// # }
666    /// ```
667    pub async fn new_from_discovered_paths(
668        storage_path: &Path,
669        table_dirs: Vec<PathBuf>,
670        config: &Config,
671        platform: Arc<Platform>,
672        #[cfg(feature = "state_machine")] schema_registry: Option<
673            Arc<RwLock<crate::schema::SchemaRegistry>>,
674        >,
675    ) -> Result<Self> {
676        let base_path = storage_path.to_path_buf();
677        let readers = Arc::new(RwLock::new(HashMap::new()));
678        let table_readers = Arc::new(RwLock::new(HashMap::new()));
679
680        let manager = Self {
681            base_path,
682            readers,
683            table_readers,
684            platform: platform.clone(),
685            config: config.clone(),
686            discovery_source: refresh::DiscoverySource::TableDirs(table_dirs.clone()),
687            refresh_lock: Arc::new(Mutex::new(())),
688            #[cfg(feature = "state_machine")]
689            schema_registry: Arc::new(RwLock::new(schema_registry)),
690            chunk_cache: build_chunk_cache(config),
691            #[cfg(test)]
692            scan_gate: std::sync::Mutex::new(None),
693        };
694
695        // Load SSTables from the provided table directories
696        manager.load_from_table_directories(table_dirs).await?;
697
698        Ok(manager)
699    }
700
701    /// Load SSTable readers from specific table directories
702    ///
703    /// This method scans each provided table directory for Data.db files and loads them.
704    /// It handles empty directories gracefully and logs warnings for individual file errors.
705    async fn load_from_table_directories(&self, table_dirs: Vec<PathBuf>) -> Result<()> {
706        let mut readers = self.readers.write().await;
707        let mut table_readers = self.table_readers.write().await;
708
709        tracing::debug!(
710            "SSTableManager::load_from_table_directories: processing {} directories",
711            table_dirs.len()
712        );
713
714        for table_dir in table_dirs {
715            // Check if directory exists
716            if !self.platform.fs().exists(&table_dir).await? {
717                tracing::warn!("Table directory does not exist: {:?}", table_dir);
718                continue;
719            }
720
721            tracing::debug!("SSTableManager scanning directory: {:?}", table_dir);
722
723            // Read directory contents
724            let mut dir_entries = match self.platform.fs().read_dir(&table_dir).await {
725                Ok(entries) => entries,
726                Err(e) => {
727                    tracing::warn!("Cannot read table directory {:?}: {}", table_dir, e);
728                    continue;
729                }
730            };
731
732            // Scan for Data.db files
733            let mut files_found = 0;
734            while let Some(entry) = dir_entries.next_entry().await? {
735                let path = entry.path();
736                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
737                    // Check for Cassandra SSTable data files using the *-Data.db pattern.
738                    // Skip macOS AppleDouble sidecars via is_apple_double_sidecar().
739                    // See Issue #481.
740                    if filename.ends_with("-Data.db") && !is_apple_double_sidecar(filename) {
741                        files_found += 1;
742                        tracing::debug!("SSTableManager found SSTable file: {:?}", path);
743
744                        let sstable_id = SSTableId::from_filename(filename);
745                        // Open + wire registries via the shared helper so refresh
746                        // opens readers identically (issue #1749). A per-file open
747                        // error is logged and skipped here (best-effort load).
748                        match self.open_reader_with_schema(&path).await {
749                            Ok(reader_arc) => {
750                                tracing::debug!(
751                                    "SSTableManager successfully loaded SSTable: {}",
752                                    sstable_id.0
753                                );
754
755                                // Store by SSTableId (existing)
756                                readers.insert(sstable_id, reader_arc.clone());
757
758                                // Fully-qualified "keyspace.table" key (or unqualified
759                                // fallback) via the shared keying helper (Issue #680).
760                                if let Some(key) = refresh::table_dir_table_key(&path) {
761                                    tracing::debug!(
762                                        "SSTableManager mapping table '{}' to SSTable '{}'",
763                                        key,
764                                        path.display()
765                                    );
766                                    table_readers
767                                        .entry(key)
768                                        .or_insert_with(Vec::new)
769                                        .push(reader_arc);
770                                } else {
771                                    tracing::warn!(
772                                        "SSTableManager could not extract table name from path: {}",
773                                        path.display()
774                                    );
775                                }
776                            }
777                            Err(e) => {
778                                // Log warning but continue loading other SSTables
779                                tracing::warn!("Could not load SSTable file {:?}: {}", path, e);
780                            }
781                        }
782                    }
783                }
784            }
785
786            tracing::debug!(
787                "SSTableManager directory scan complete: found {} Data.db files in {:?}",
788                files_found,
789                table_dir
790            );
791        }
792
793        tracing::debug!("SSTableManager total SSTables loaded: {}", readers.len());
794        tracing::debug!(
795            "SSTableManager tables discovered: {:?}",
796            table_readers.keys().collect::<Vec<_>>()
797        );
798
799        Ok(())
800    }
801
802    /// Load existing SSTable files from disk
803    ///
804    /// Scans the base path recursively (up to 3 levels deep) to find Data.db files.
805    /// This supports both flat layouts (Data.db directly in base_path) and Cassandra-style
806    /// directory structures (keyspace/table_name/Data.db).
807    async fn load_existing_sstables(&self) -> Result<()> {
808        // Check if directory exists first
809        if !self.platform.fs().exists(&self.base_path).await? {
810            return Ok(()); // No directory, no SSTables to load
811        }
812
813        // Collect all Data.db paths by walking up to 3 levels deep
814        let data_files: Vec<PathBuf> =
815            Self::find_data_files(&self.platform, &self.base_path, MAX_SSTABLE_SCAN_DEPTH).await?;
816
817        if data_files.is_empty() {
818            return Ok(());
819        }
820
821        let mut readers = self.readers.write().await;
822        let mut table_readers = self.table_readers.write().await;
823
824        // Pre-compute for the table name fallback heuristic
825        let base_dir_name = self
826            .base_path
827            .file_name()
828            .and_then(|n| n.to_str())
829            .unwrap_or("")
830            .to_string();
831
832        for path in data_files {
833            let filename = match path.file_name().and_then(|n| n.to_str()) {
834                Some(f) => f.to_string(),
835                None => continue,
836            };
837            let sstable_id = SSTableId::from_filename(&filename);
838            // Open + wire registries via the shared helper so refresh opens
839            // readers identically (issue #1749). Don't fail the whole load if one
840            // file is problematic — skip it (best-effort initial load).
841            match self.open_reader_with_schema(&path).await {
842                Ok(reader_arc) => {
843                    // Store by SSTableId
844                    readers.insert(sstable_id, reader_arc.clone());
845
846                    // Fully-qualified "keyspace.table" key (base-dir-excluded, with
847                    // header fallback) via the shared keying helper (Issue #680).
848                    let table_key = refresh::base_path_table_key(
849                        &path,
850                        &base_dir_name,
851                        &reader_arc.header().table_name,
852                    );
853
854                    if let Some(key) = table_key {
855                        tracing::debug!(
856                            "SSTableManager mapping table '{}' to SSTable '{}'",
857                            key,
858                            path.display()
859                        );
860                        table_readers
861                            .entry(key)
862                            .or_insert_with(Vec::new)
863                            .push(reader_arc);
864                    } else {
865                        tracing::warn!(
866                            "SSTableManager could not determine table name for: {}",
867                            path.display()
868                        );
869                    }
870                }
871                Err(_) => {
872                    // Skip problematic SSTable files during initialization
873                    tracing::warn!("Could not load SSTable file: {:?}", path);
874                }
875            }
876        }
877
878        Ok(())
879    }
880
881    /// Recursively find all *-Data.db files up to `max_depth` levels deep
882    fn find_data_files<'a>(
883        platform: &'a Platform,
884        dir: &'a Path,
885        max_depth: usize,
886    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathBuf>>> + Send + 'a>>
887    {
888        let dir = dir.to_path_buf();
889        Box::pin(async move {
890            let mut results = Vec::new();
891
892            let mut dir_entries = match platform.fs().read_dir(&dir).await {
893                Ok(entries) => entries,
894                Err(_) => return Ok(results),
895            };
896
897            while let Some(entry) = dir_entries.next_entry().await? {
898                let path = entry.path();
899                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
900                    // Skip macOS AppleDouble sidecars via is_apple_double_sidecar().
901                    // See Issue #481.
902                    if filename.ends_with("-Data.db") && !is_apple_double_sidecar(filename) {
903                        results.push(path);
904                    } else if max_depth > 0 {
905                        // Check if it's a directory and recurse
906                        if entry
907                            .file_type()
908                            .await
909                            .map(|ft| ft.is_dir())
910                            .unwrap_or(false)
911                        {
912                            let sub_results =
913                                Self::find_data_files(platform, &path, max_depth - 1).await?;
914                            results.extend(sub_results);
915                        }
916                    }
917                }
918            }
919
920            Ok(results)
921        })
922    }
923
924    /// Create a new SSTable from MemTable data
925    ///
926    /// NOTE: SSTable writing removed in Issue #176 (writer.rs deleted).
927    /// This method is feature-gated behind 'experimental' but currently unimplemented.
928    #[cfg(feature = "experimental")]
929    pub async fn create_from_memtable(
930        &self,
931        _data: Vec<(TableId, RowKey, ScanRow)>,
932    ) -> Result<SSTableId> {
933        Err(crate::error::Error::unsupported_format(
934            "SSTable writing removed in Issue #176 - writer.rs deleted",
935        ))
936    }
937
938    #[cfg(not(feature = "experimental"))]
939    pub async fn create_from_memtable(
940        &self,
941        _data: Vec<(TableId, RowKey, ScanRow)>,
942    ) -> Result<SSTableId> {
943        Err(crate::error::Error::unsupported_format(
944            "SSTable writing requires experimental feature",
945        ))
946    }
947
948    /// Get a value by key from all SSTables with proper tombstone merging
949    #[cfg(feature = "tombstones")]
950    pub async fn get(&self, table_id: &TableId, key: &RowKey) -> Result<Option<ScanRow>> {
951        // Resolve the applicable reader list FIRST, exactly like the non-tombstones
952        // `get()` path (issue #1321). The previous code iterated EVERY reader in
953        // `self.readers` and passed one global relaxed `fully_qualified_match` flag
954        // to all of them, so same-named tables in OTHER keyspaces passed the relaxed
955        // BTI guard and wrongly contributed values/tombstones to the merge — a
956        // cross-keyspace data-bleed bug. `resolve_reader_list` returns precisely the
957        // readers for the resolved target table across generations, so the relaxed
958        // guard can only ever apply to the readers that ARE the target table; a
959        // wrong-keyspace same-named reader is never in the merge set.
960        //
961        // Issue #1591: snapshot the resolved readers + the authoritative
962        // `fully_qualified_match` signal and DROP the read guard before any I/O.
963        let (reader_list, fully_qualified_match) = self.resolve_reader_snapshot(table_id).await;
964        if reader_list.is_empty() {
965            return Ok(None);
966        }
967
968        let mut all_values = Vec::new();
969
970        // Collect each applicable generation's value (tombstone-merge semantics are
971        // unchanged: still build a `GenerationValue` per reader and resolve via
972        // `TombstoneMerger::merge_generations`). Only the SET of readers being merged
973        // changed — the resolved list instead of every reader globally.
974        for reader in &reader_list {
975            if let Some(value) = reader
976                .get_with_resolution(table_id, key, fully_qualified_match)
977                .await?
978            {
979                let generation = reader.generation;
980                let write_time = reader.extract_write_time_from_entry(key, &value);
981
982                let gen_value = GenerationValue {
983                    value,
984                    metadata: EntryMetadata {
985                        write_time,
986                        generation,
987                        ttl: None, // Would be extracted from SSTable metadata
988                    },
989                };
990                all_values.push(gen_value);
991            }
992        }
993
994        // Use tombstone merger to resolve conflicts across generations
995        let merger = TombstoneMerger::new();
996        merger.merge_generations(all_values)
997    }
998
999    /// Get a value by key from all SSTables (simple version without tombstone merging)
1000    ///
1001    /// Uses `table_readers` (keyed by fully-qualified `"keyspace.table"`) so that only the
1002    /// SSTables for the requested table are searched (Issue #680).  Same-named tables in
1003    /// different keyspaces (e.g. `test_basic.simple_table` and `test_oa.simple_table`) are
1004    /// now correctly distinguished.
1005    ///
1006    /// Lookup order:
1007    ///   1. Exact match on the full `table_id` string (e.g. `"test_basic.simple_table"`)
1008    ///   2. Unqualified table name (e.g. `"simple_table"`) — for backward compatibility
1009    ///      with flat/non-Cassandra directory layouts that have no keyspace parent.
1010    #[cfg(not(feature = "tombstones"))]
1011    pub async fn get(&self, table_id: &TableId, key: &RowKey) -> Result<Option<ScanRow>> {
1012        // Issue #1591: snapshot the resolved readers + the authoritative
1013        // `fully_qualified_match` signal and DROP the read guard before any I/O,
1014        // so a queued writer never FIFO-parks this point read behind a slow scan.
1015        //
1016        // `fully_qualified_match`: did resolution match the FULLY-QUALIFIED
1017        // `keyspace.table` key exactly, or fall back to the bare table name? An
1018        // unqualified query is treated as an exact match (no keyspace to mismatch).
1019        // This authoritative signal gates the get() point-lookup table-consistency
1020        // guard exactly like the seek path (#1284): only an exact FQ match may relax
1021        // to a name-only check across a header-keyspace divergence; a fully-qualified
1022        // query resolved via the bare-name fallback keeps strict keyspace matching so
1023        // get() never returns another keyspace's same-named rows (issue #1321).
1024        let (reader_list, fully_qualified_match) = self.resolve_reader_snapshot(table_id).await;
1025
1026        // Return the first value found across all SSTables for this table
1027        for reader in &reader_list {
1028            if let Some(value) = reader
1029                .get_with_resolution(table_id, key, fully_qualified_match)
1030                .await?
1031            {
1032                return Ok(Some(value));
1033            }
1034        }
1035
1036        Ok(None)
1037    }
1038
1039    /// Scan a range of keys from all SSTables for a table.
1040    ///
1041    /// # Arguments
1042    /// * `table_id` - The table to scan
1043    /// * `start_key` - Optional start key for range scan
1044    /// * `end_key` - Optional end key for range scan
1045    /// * `limit` - Optional limit on number of results
1046    /// * `schema` - Optional table schema for schema-aware parsing. When provided,
1047    ///   enables accurate type detection and avoids heuristic-based parsing.
1048    ///   Strongly recommended for Cassandra 5.0+ formats.
1049    ///
1050    /// Cross-generation reconciliation (last-write-wins + tombstone shadowing) is
1051    /// applied via the authoritative k-way merger when more than one SSTable
1052    /// generation backs the table and `write-support` + a schema are available;
1053    /// otherwise rows from each reader are concatenated. That concat fallback is
1054    /// the documented multi-generation limitation (Issue #883) and is now
1055    /// IDENTICAL across every feature build: the `tombstones` build takes exactly
1056    /// this path too (it no longer runs its own partition-keyed merge). So no
1057    /// build regresses relative to the default — a `tombstones`-without-
1058    /// `write-support` multi-generation read behaves the same as the default
1059    /// `not(tombstones)`-without-`write-support` build, and the prior `tombstones`
1060    /// "merge" it replaces was the row-collapsing bug, not real reconciliation.
1061    ///
1062    /// Issue #1085: this is the SINGLE `scan` implementation for every feature
1063    /// build. The former `#[cfg(feature = "tombstones")]` variant grouped per-row
1064    /// results into a `HashMap` keyed on `RowKey` (which carries only the
1065    /// partition-key bytes, no clustering) and ran `TombstoneMerger`, so it
1066    /// collapsed all clustering rows of a partition into one — a full `SELECT *`
1067    /// over a clustered table returned ~one row per partition. Concatenating
1068    /// per-reader rows here (and reconciling only ACROSS generations) is correct
1069    /// for clustered tables in every build.
1070    ///
1071    /// Lookup order (Issue #680):
1072    ///   1. Exact match on the full `table_id` string (e.g. `"test_basic.simple_table"`)
1073    ///   2. Unqualified table name (e.g. `"simple_table"`) — for backward compatibility
1074    ///      with flat/non-Cassandra directory layouts that have no keyspace parent.
1075    pub async fn scan(
1076        &self,
1077        table_id: &TableId,
1078        start_key: Option<&RowKey>,
1079        end_key: Option<&RowKey>,
1080        limit: Option<usize>,
1081        schema: Option<&crate::schema::TableSchema>,
1082    ) -> Result<Vec<(RowKey, ScanRow)>> {
1083        tracing::debug!("SSTableManager::scan - Scanning table_id='{}'", table_id);
1084
1085        // Issue #1591: snapshot the reader list and DROP the read guard before any
1086        // I/O. Holding it across the whole scan let one queued writer FIFO-park
1087        // every later point read behind the slowest in-flight scan.
1088        let (reader_list, _fully_qualified_match) = self.resolve_reader_snapshot(table_id).await;
1089
1090        if reader_list.is_empty() {
1091            tracing::debug!(
1092                "SSTableManager::scan - No readers found for table '{}'",
1093                table_id
1094            );
1095            return Ok(Vec::new());
1096        }
1097
1098        tracing::debug!(
1099            "SSTableManager::scan - Found {} readers for table '{}'",
1100            reader_list.len(),
1101            table_id
1102        );
1103
1104        // Issue #883: when a table directory holds more than one SSTable
1105        // generation, plain concatenation of each reader's live rows is wrong —
1106        // it duplicates rows that exist in several generations and resurrects
1107        // rows deleted in a later generation (each reader suppresses only its
1108        // OWN tombstones). Reconcile across generations with the same
1109        // last-write-wins + tombstone-shadowing rule compaction uses, reusing
1110        // the authoritative k-way merger (write-support only; requires schema).
1111        #[cfg(feature = "write-support")]
1112        if reader_list.len() > 1 {
1113            if let Some(schema) = schema {
1114                match generation_merge::merge_generations_for_read(
1115                    &reader_list,
1116                    schema,
1117                    start_key,
1118                    end_key,
1119                    limit,
1120                    None,
1121                )
1122                .await
1123                {
1124                    Ok(merged) => {
1125                        tracing::debug!(
1126                            "SSTableManager::scan - cross-generation merge produced {} rows",
1127                            merged.len()
1128                        );
1129                        return Ok(merged);
1130                    }
1131                    Err(e) => {
1132                        // Never fail a read because the merge path hit an
1133                        // unsupported format; fall back to concatenation.
1134                        tracing::warn!(
1135                            "SSTableManager::scan - cross-generation merge failed for '{}' ({}); \
1136                             falling back to per-reader concatenation",
1137                            table_id,
1138                            e
1139                        );
1140                    }
1141                }
1142            }
1143        }
1144
1145        // Each reader returns rows already in Cassandra token order; k-way
1146        // merge the per-reader streams in token order (issue #1580) instead of
1147        // concatenating + re-sorting by RAW key bytes (wrong global order +
1148        // O(n log n) on the async worker). The merge is O(n log k) and
1149        // early-exits once `limit` is satisfied.
1150        let mut per_reader = Vec::with_capacity(reader_list.len());
1151        for reader in &reader_list {
1152            #[cfg(test)]
1153            {
1154                // Issue #1591: deterministic pause during the scan's I/O. On the
1155                // fixed path the `table_readers` read guard is already dropped
1156                // here (we operate on a cloned snapshot), so a test can prove no
1157                // guard is held across the scan.
1158                let gate = self.scan_gate.lock().ok().and_then(|g| g.clone());
1159                scan_gate::wait_if_armed(&gate).await;
1160            }
1161            let results = reader
1162                .scan(table_id, start_key, end_key, None, schema)
1163                .await?;
1164            per_reader.push(results);
1165        }
1166
1167        let all_results = scan_merge::kway_merge_token_order(per_reader, limit);
1168
1169        tracing::debug!(
1170            "SSTableManager::scan - Returning {} final results (token-ordered k-way merge)",
1171            all_results.len()
1172        );
1173
1174        Ok(all_results)
1175    }
1176
1177    /// Partition-targeted scan: return only the rows for a single partition key,
1178    /// touching only the SSTables whose bloom filter / BTI trie admit the key.
1179    ///
1180    /// This is the storage-layer fast path for a fully-constrained `WHERE pk = ?`
1181    /// (Issue #949). Rather than scanning every SSTable for the table and filtering
1182    /// in memory, it prunes the reader set with
1183    /// [`might_contain_partition`](reader::SSTableReader::might_contain_partition)
1184    /// — an O(1) bloom check for BIG format, an O(log n) trie walk for BTI — and
1185    /// only parses the surviving candidates. On a table backed by thousands of
1186    /// SSTables, a single-partition read drops from "open and scan all of them" to
1187    /// "scan only the handful that can hold the key".
1188    ///
1189    /// Output matches filtering the full [`scan`](Self::scan) result down to
1190    /// `partition_key`: the same per-reader parse and the same cross-generation
1191    /// reconciliation run, just over the pruned candidate set. Concretely, with
1192    /// more than one candidate generation this drives the authoritative k-way
1193    /// merge (write-support, schema present); the single-candidate and concat
1194    /// fallbacks behave exactly as the corresponding `scan` paths do — including
1195    /// sharing `scan`'s known multi-generation concat limitation (Issue #883) when
1196    /// the merge is unavailable. The caller still applies its own predicate
1197    /// evaluation, so any over-inclusion (e.g. a BTI prefix-collision candidate) is
1198    /// filtered out downstream.
1199    ///
1200    /// Gated on `not(tombstones)` because the bloom/BTI prune fast path it relies
1201    /// on ([`scan_partition_clustering`](reader::SSTableReader::scan_partition_clustering))
1202    /// is itself `not(tombstones)`-only. Under `tombstones` the executor falls back
1203    /// to a full [`scan`](Self::scan) + predicate filter (since #1085, `scan` is the
1204    /// same correct implementation in both builds, so the fallback is correct — just
1205    /// without the single-partition prune).
1206    ///
1207    /// `partition_key` is the raw on-disk partition-key bytes produced by
1208    /// [`encode_partition_key_columns`](crate::storage::partition_key_codec::encode_partition_key_columns),
1209    /// which match the bytes the bloom filter, Index.db/BTI trie, and scan RowKeys
1210    /// are keyed on.
1211    ///
1212    /// Within-SSTable seek (Issue #953): for the SINGLE-candidate case (the common
1213    /// point-lookup path) this seeks directly to the partition's `Data.db` offset
1214    /// — resolved via the BTI Partitions.db trie or the BIG `Index.db` — and
1215    /// decodes ONLY that partition via
1216    /// [`scan_single_partition_clustering`](reader::SSTableReader::scan_single_partition_clustering),
1217    /// instead of full-parsing the candidate and retaining one partition. The
1218    /// decode reuses the scan path's `parse_block_emit`, so its output is
1219    /// byte-for-byte identical to `scan(...).retain(matches_key)`; when the offset
1220    /// cannot be resolved authoritatively (no `Index.db` hit, or an unsupported
1221    /// format) it falls back to the full scan + retain for that candidate. The
1222    /// MULTI-candidate path is unchanged: it still reconciles via the k-way merge
1223    /// (or the per-candidate concat fallback), so cross-generation LWW / tombstone
1224    /// shadowing (#883) is preserved.
1225    ///
1226    /// Returns `(rows, engaged)`. On this build `engaged` is always `true`: the
1227    /// underlying [`scan_partition_clustering`](Self::scan_partition_clustering)
1228    /// prunes the SSTable set via `might_contain_partition` before decoding, so a
1229    /// caller may honestly report a partition-targeted access path. The
1230    /// `tombstones`-build counterpart returns `false` because it has no prune
1231    /// (Epic #951, honest access paths).
1232    #[cfg(not(feature = "tombstones"))]
1233    pub async fn scan_partition(
1234        &self,
1235        table_id: &TableId,
1236        partition_key: &[u8],
1237        schema: Option<&crate::schema::TableSchema>,
1238    ) -> Result<(Vec<(RowKey, ScanRow)>, bool)> {
1239        // The clustering-aware path always prunes via the bloom/BTI candidate
1240        // filter, so the partition-targeted access path is genuinely engaged
1241        // regardless of whether the within-partition clustering seek narrowed.
1242        let (rows, _clustering_engaged) = self
1243            .scan_partition_clustering(table_id, partition_key, None, schema)
1244            .await?;
1245        Ok((rows, true))
1246    }
1247
1248    /// Metadata-carrying partition-targeted scan (Issue #962, Epic #951).
1249    ///
1250    /// The WRITETIME/TTL-projection sibling of [`scan_partition`](Self::scan_partition):
1251    /// it returns only the rows for the single partition identified by the raw
1252    /// `partition_key` bytes, WITH per-cell write metadata
1253    /// ([`CellWriteMetadata`] — write timestamp / TTL), while still PRUNING the
1254    /// SSTable set down to the candidates whose bloom filter / BTI trie admit the
1255    /// key. A `SELECT WRITETIME(col), TTL(col) ... WHERE pk = ?` therefore opens
1256    /// only the handful of SSTables that can hold the partition, never all N — the
1257    /// SSTable-level prune is the must-have that distinguishes this from the
1258    /// full-table [`scan_with_cell_metadata`](Self::scan_with_cell_metadata).
1259    ///
1260    /// Output is identical to filtering `scan_with_cell_metadata(table, ..)` down to
1261    /// `partition_key`: the same per-reader metadata decode and the same
1262    /// cross-generation reconciliation run, just over the pruned candidate set, so
1263    /// the caller's post-scan predicate evaluation is a pure correctness backstop
1264    /// (it removes any bloom/BTI false-positive over-inclusion).
1265    ///
1266    /// Reconciliation mirrors `scan_partition`:
1267    /// - More than one candidate generation (write-support + schema): drive the
1268    ///   authoritative k-way merge via
1269    ///   `generation_merge::merge_generations_for_read_with_metadata` TARGETED to
1270    ///   just this partition. This preserves
1271    ///   per-cell cross-generation LWW / tombstone shadowing for WRITETIME/TTL
1272    ///   (Issue #885) on the targeted path.
1273    /// - Otherwise: decode each candidate via the reader's metadata path and retain
1274    ///   this partition's rows, concatenating across candidates.
1275    ///
1276    /// Within-SSTable decode currently full-decodes each surviving candidate's
1277    /// metadata and retains the partition; the SSTable-level prune (avoiding the
1278    /// full TABLE/SSTable scan) is the property #962 requires. A within-partition
1279    /// metadata seek (bounding the decode to the partition's `Data.db` offset, as
1280    /// `scan_single_partition_clustering` does for the plain path) is a documented
1281    /// follow-up.
1282    ///
1283    /// Gated on `not(tombstones)` to match the `scan_partition` variant it parallels.
1284    ///
1285    /// Returns `(rows, engaged)`. On this build `engaged` is always `true`: the
1286    /// candidate set is pruned via `might_contain_partition` before any decode, so
1287    /// the partition-targeted metadata access path is genuinely engaged. The
1288    /// `tombstones`-build counterpart returns `false` (no prune; full metadata
1289    /// scan + retain) so the caller reports an honest fallback (Epic #951).
1290    /// Prune a reader snapshot to the candidate generations that admit
1291    /// `partition_key`, hoisting the BTI key hash+encoding to ONCE per read instead
1292    /// of once per candidate (issue #1575 / C4).
1293    ///
1294    /// When ANY candidate is a BTI ("da") reader, the Murmur3-based byte-comparable
1295    /// trie key is computed a SINGLE time and reused for every candidate's trie
1296    /// prune (`might_contain_partition_encoded`); on `main` each candidate re-encoded
1297    /// the same key, so an N-generation fan-out paid N identical Murmur3 hashes
1298    /// (`KEY_HASH_CALLS == N`) where C4 pays 1. A BIG (`nb`) candidate has no BTI
1299    /// encoding to hoist — its raw-key bloom check runs unchanged — so a non-BTI or
1300    /// mixed candidate set stays correct. The pruning decision (and therefore the
1301    /// resulting rows) is byte-identical to the per-candidate path.
1302    /// Returns `(admitted, pruned)`: `admitted` are the SAME byte-for-byte
1303    /// filtered candidates the original single-`Vec` `prune_candidates` returned;
1304    /// `pruned` are every reader EXCLUDED by the presence-oracle check — each is,
1305    /// by construction, a definitive-negative exclusion (the filter's ONLY
1306    /// criterion), so `pruned` is exactly the set
1307    /// [`verify_pruned_candidates`](Self::verify_pruned_candidates) needs to
1308    /// authoritatively re-check (issue #2163's core silent-miss case: a false
1309    /// negative HERE, at the multi-generation candidate prune, would otherwise
1310    /// drop that SSTable from the read with no verification ever reached — the
1311    /// per-reader `get_with_resolution` verify hook only covers a single reader's
1312    /// OWN point read, never a candidate eliminated at this layer).
1313    #[cfg(not(feature = "tombstones"))]
1314    fn prune_candidates(
1315        readers: &[Arc<reader::SSTableReader>],
1316        partition_key: &[u8],
1317    ) -> (
1318        Vec<Arc<reader::SSTableReader>>,
1319        Vec<Arc<reader::SSTableReader>>,
1320    ) {
1321        use crate::storage::sstable::bti::encode_partition_key_for_bti_trie;
1322        // Encode once iff a BTI reader is present (BIG readers ignore `encoded`).
1323        let encoded = readers
1324            .iter()
1325            .any(|r| r.is_bti())
1326            .then(|| encode_partition_key_for_bti_trie(partition_key));
1327        readers.iter().cloned().partition(|r| {
1328            // BTI candidates prune via `might_contain_partition_encoded` (fed the
1329            // hoisted C4 encoding), the SAME single emit site the raw-key BIG
1330            // bloom prune below uses (issue #2163): the `Partitions.db` trie is
1331            // the authoritative presence oracle, so a `false` (trie miss) is a
1332            // definitive absent (drop) — recorded once as
1333            // `cqlite.read.sstables_pruned{format=bti}` — and an `Err` (corrupt
1334            // trie) is conservatively kept (not pruned, not recorded). This is
1335            // congruent with the façade's resolution — a BTI trie result is
1336            // authoritative. BIG candidates DELIBERATELY stay on the bloom-based
1337            // `might_contain_partition`: a BIG `Index.db` miss is NOT a definitive
1338            // absent (#1572 truncated-index invariant), so pruning a BIG reader on
1339            // the façade's index probe would drop a candidate that actually holds
1340            // the partition. Only the bloom filter never yields a false negative.
1341            // Sharing ONE emit site (`might_contain_partition[_encoded]`) for both
1342            // formats means a caller that also `locate`s the same key afterward
1343            // (the actual read) never double-emits: `locate`/`locate_encoded`
1344            // themselves never call `emit_sstable_pruned`.
1345            match (r.is_bti(), &encoded) {
1346                (true, Some(enc)) => r.might_contain_partition_encoded(partition_key, enc),
1347                // Non-BTI reader (or, defensively, no encoding): bloom prune.
1348                _ => r.might_contain_partition(partition_key),
1349            }
1350        })
1351    }
1352
1353    /// Verify presence-oracle negatives for SSTables EXCLUDED by
1354    /// [`prune_candidates`](Self::prune_candidates) — issue #2163's core
1355    /// silent-miss case, roborev r6. A bloom/BTI-trie false negative during a
1356    /// MULTI-generation candidate prune would otherwise drop that SSTable from
1357    /// the read entirely, unverified: the per-reader `get_with_resolution` opt-in
1358    /// verify hook (`data_access/mod.rs`) never runs for a candidate eliminated
1359    /// one layer up, at THIS prune site — a candidate excluded here never reaches
1360    /// `get_with_resolution` at all for this read.
1361    ///
1362    /// STRICTLY inside the opt-in switch (default-off = zero extra work,
1363    /// unchanged behaviour): when
1364    /// [`presence_verification::enabled`](reader::presence_verification::enabled)
1365    /// is `false` (the default), this returns immediately without touching
1366    /// `pruned` at all. When enabled, each pruned reader is verified via
1367    /// [`SSTableReader::verify_presence_oracle_negative`] (an authoritative
1368    /// confirmation scan): a genuine contradiction increments
1369    /// `cqlite.read.bloom.false_negatives` EXACTLY ONCE per contradicted
1370    /// negative (the verify method's own single per-call emit — this loop calls
1371    /// it once per pruned reader, never more). Any `Err` fails OPEN (the
1372    /// caller's admitted `candidates` list is never touched) but is surfaced
1373    /// LOUDLY via the SAME loud-failure contract `get_with_resolution` uses
1374    /// (roborev r4): an error-level log with context, and a record through the
1375    /// EXISTING error-rate signal (`cqlite.errors.total{subsystem=reader}`,
1376    /// issue #1038) — never a new metric.
1377    #[cfg(not(feature = "tombstones"))]
1378    async fn verify_pruned_candidates(
1379        pruned: &[Arc<reader::SSTableReader>],
1380        table_id: &TableId,
1381        partition_key: &[u8],
1382    ) {
1383        if !reader::presence_verification::enabled() {
1384            return;
1385        }
1386        for r in pruned {
1387            if let Err(e) = r
1388                .verify_presence_oracle_negative(table_id, partition_key)
1389                .await
1390            {
1391                tracing::error!(
1392                    error = %e,
1393                    sstable_format = r.sstable_format_label(),
1394                    "opt-in presence-oracle false-negative verification scan FAILED for a \
1395                     prune_candidates-excluded SSTable — the read itself is unaffected \
1396                     (fail-open), but this soundness check could not run for this SSTable and \
1397                     needs investigation"
1398                );
1399                crate::observability::record_error(&e, "reader");
1400            }
1401        }
1402    }
1403
1404    #[cfg(not(feature = "tombstones"))]
1405    pub async fn scan_partition_with_cell_metadata(
1406        &self,
1407        table_id: &TableId,
1408        partition_key: &[u8],
1409        schema: Option<&crate::schema::TableSchema>,
1410    ) -> Result<(
1411        Vec<(RowKey, ScanRow, HashMap<String, CellWriteMetadata>)>,
1412        bool,
1413    )> {
1414        // Issue #1591: snapshot the reader list and DROP the read guard before any
1415        // I/O (bloom/BTI prune, per-candidate decode, cross-generation merge).
1416        let (reader_list, _fully_qualified_match) = self.resolve_reader_snapshot(table_id).await;
1417        if reader_list.is_empty() {
1418            return Ok((Vec::new(), true));
1419        }
1420
1421        // Prune: keep only SSTables whose bloom filter / BTI trie admit the key.
1422        // This is the property #962 requires — only candidates are opened, never N.
1423        // C4 (#1575): the BTI key hash+encoding is hoisted to once per read here.
1424        let (candidates, pruned) = Self::prune_candidates(&reader_list, partition_key);
1425
1426        // Issue #2163 (roborev r6): opt-in verification of every EXCLUDED
1427        // candidate — a strict no-op unless the switch is on. Runs BEFORE the
1428        // `candidates.is_empty()` early-return below, since the core silent-miss
1429        // case is exactly "every admitted candidate came up empty because a
1430        // false negative wrongly pruned the ONE generation holding the key".
1431        Self::verify_pruned_candidates(&pruned, table_id, partition_key).await;
1432
1433        tracing::debug!(
1434            "SSTableManager::scan_partition_with_cell_metadata - {}/{} SSTables admit partition \
1435             key (len={}) for '{}'",
1436            candidates.len(),
1437            reader_list.len(),
1438            partition_key.len(),
1439            table_id
1440        );
1441
1442        if candidates.is_empty() {
1443            return Ok((Vec::new(), true));
1444        }
1445
1446        let matches_key = |entry: &(RowKey, ScanRow, HashMap<String, CellWriteMetadata>)| {
1447            entry.0.as_bytes() == partition_key
1448        };
1449
1450        // Multiple candidate generations may hold the same partition; reconcile
1451        // with the same authoritative metadata-aware k-way merge the full metadata
1452        // scan uses (write-support only, schema present), TARGETED to just this
1453        // partition (issue #1579): the merge keeps only `partition_key`'s rows and
1454        // stops as soon as it finds them, byte-identical to the former
1455        // full-merge-then-`retain(matches_key)` but without materializing every
1456        // other partition. This preserves per-cell cross-generation WRITETIME/TTL.
1457        #[cfg(feature = "write-support")]
1458        if candidates.len() > 1 {
1459            if let Some(schema) = schema {
1460                let target = RowKey::new(partition_key.to_vec());
1461                match generation_merge::merge_generations_for_read_with_metadata(
1462                    &candidates,
1463                    schema,
1464                    None,
1465                    None,
1466                    None,
1467                    Some(&target),
1468                )
1469                .await
1470                {
1471                    Ok(merged) => {
1472                        // Work-counter gate (Issue #958): the merge parsed every
1473                        // surviving candidate; `merged` (already the target partition
1474                        // only) is exactly the partitions this lookup returns.
1475                        work_counters::add_sstables_scanned(candidates.len() as u64);
1476                        work_counters::add_partitions_parsed(merged.len() as u64);
1477                        return Ok((merged, true));
1478                    }
1479                    Err(e) => {
1480                        tracing::warn!(
1481                            "SSTableManager::scan_partition_with_cell_metadata - cross-generation \
1482                             metadata merge failed for '{}' ({}); falling back to per-reader \
1483                             concatenation",
1484                            table_id,
1485                            e
1486                        );
1487                    }
1488                }
1489            }
1490        }
1491
1492        // Single candidate (common case) or the multi-candidate concat fallback:
1493        // decode each candidate's metadata and retain this partition's rows.
1494        let mut all_results = Vec::new();
1495        for reader in &candidates {
1496            // Work-counter gate (Issue #958): one real Data.db touch per surviving
1497            // candidate. Counted here (not at prune time) so the counter reflects
1498            // SSTables actually opened/scanned.
1499            work_counters::add_sstables_scanned(1);
1500
1501            let mut results = reader
1502                .scan_with_cell_metadata(table_id, None, None, None, schema)
1503                .await?;
1504            results.retain(matches_key);
1505            all_results.append(&mut results);
1506        }
1507        // #1917: rows here already share `partition_key` (prior retain) so this is a no-op today; kept for future multi-partition safety + parity with the IN fan-out / streaming scan's token order (raw-byte = #1580 wrong-order).
1508        if candidates.len() > 1 {
1509            all_results.sort_by(|a, b| cmp_partition_keys_by_token(a.0.as_bytes(), b.0.as_bytes()));
1510        }
1511        work_counters::add_partitions_parsed(all_results.len() as u64);
1512        Ok((all_results, true))
1513    }
1514
1515    /// `tombstones`-build counterpart of
1516    /// [`scan_partition_with_cell_metadata`](Self::scan_partition_with_cell_metadata).
1517    ///
1518    /// That build has no bloom-prune metadata path, so a fully-constrained
1519    /// `WHERE pk = ?` WRITETIME/TTL read is served by scanning with metadata and
1520    /// filtering to the partition key, matching the `not(tombstones)` output while
1521    /// keeping the query executor free of `tombstones` cfg branching.
1522    ///
1523    /// Returns `(rows, engaged)` with `engaged == false`: this is a full metadata
1524    /// scan + retain with NO SSTable prune, so the caller MUST report an honest
1525    /// fallback access path (`FallbackReason::TombstonesBuildNoPrune`) rather than a
1526    /// targeted label, even though the rows are byte-identical to the pruned build
1527    /// (Epic #951, honest access paths).
1528    #[cfg(feature = "tombstones")]
1529    pub async fn scan_partition_with_cell_metadata(
1530        &self,
1531        table_id: &TableId,
1532        partition_key: &[u8],
1533        schema: Option<&crate::schema::TableSchema>,
1534    ) -> Result<(
1535        Vec<(
1536            RowKey,
1537            ScanRow,
1538            HashMap<String, crate::types::CellWriteMetadata>,
1539        )>,
1540        bool,
1541    )> {
1542        let mut rows = self
1543            .scan_with_cell_metadata(table_id, None, None, None, schema)
1544            .await?;
1545        rows.retain(|entry| entry.0.as_bytes() == partition_key);
1546        Ok((rows, false))
1547    }
1548
1549    /// Clustering-slice-aware partition-targeted scan (Issue #954, Epic #951).
1550    ///
1551    /// Identical to [`scan_partition`](Self::scan_partition) but, when `clustering`
1552    /// is `Some(slice)` AND exactly one candidate SSTable admits the key AND that
1553    /// candidate's single-partition seek can use its authoritative row index, the
1554    /// within-partition decode is bounded to the row-index block(s) covering the
1555    /// requested clustering range — so a `WHERE pk = ? AND ck </>/= ?` slice over a
1556    /// wide partition decodes O(matched rows + index block), not the whole
1557    /// partition.
1558    ///
1559    /// Returns `(rows, clustering_seek_engaged)`. `clustering_seek_engaged` is
1560    /// `true` only when the within-partition clustering narrowing actually bounded
1561    /// the decode (so the caller may report
1562    /// [`AccessPath::ClusteringSlice`](crate::query::access_path::AccessPath::ClusteringSlice));
1563    /// it is `false` for the multi-candidate / merge / full-decode fallbacks,
1564    /// which still return correct rows for the honest `PartitionLookup` path. The
1565    /// rows are ALWAYS the full partition (or its clustering-narrowed superset):
1566    /// the caller's post-scan `evaluate_leaf` applies the exact clustering bound,
1567    /// so output is byte-identical regardless of whether the seek engaged.
1568    #[cfg(not(feature = "tombstones"))]
1569    pub async fn scan_partition_clustering(
1570        &self,
1571        table_id: &TableId,
1572        partition_key: &[u8],
1573        clustering: Option<&reader::ClusteringSlice>,
1574        schema: Option<&crate::schema::TableSchema>,
1575    ) -> Result<(Vec<(RowKey, ScanRow)>, bool)> {
1576        // Issue #1591: snapshot the reader list + the authoritative
1577        // `fully_qualified_match` signal and DROP the read guard before any I/O.
1578        //
1579        // `fully_qualified_match`: did resolution match the FULLY-QUALIFIED
1580        // `keyspace.table` key exactly, or fall back to the bare table name? An
1581        // unqualified query is treated as an exact match (no keyspace to mismatch).
1582        // This authoritative signal gates the seek's table-consistency guard: only
1583        // an exact FQ match may relax to a name-only check across a header-keyspace
1584        // divergence; a fully-qualified query resolved via the bare-name fallback
1585        // keeps strict keyspace matching so it never returns another keyspace's
1586        // same-named rows (#1284 review).
1587        let (reader_list, fully_qualified_match) = self.resolve_reader_snapshot(table_id).await;
1588        if reader_list.is_empty() {
1589            return Ok((Vec::new(), false));
1590        }
1591
1592        // Prune: keep only SSTables whose bloom filter / BTI trie admit the key.
1593        // C4 (#1575): the BTI key hash+encoding is hoisted to once per read here.
1594        let (candidates, pruned) = Self::prune_candidates(&reader_list, partition_key);
1595
1596        // Issue #2163 (roborev r6): opt-in verification of every EXCLUDED
1597        // candidate — a strict no-op unless the switch is on. See
1598        // `verify_pruned_candidates` for the fail-open / loud-failure contract.
1599        Self::verify_pruned_candidates(&pruned, table_id, partition_key).await;
1600
1601        tracing::debug!(
1602            "SSTableManager::scan_partition - {}/{} SSTables admit partition key (len={}) for '{}'",
1603            candidates.len(),
1604            reader_list.len(),
1605            partition_key.len(),
1606            table_id
1607        );
1608
1609        if candidates.is_empty() {
1610            return Ok((Vec::new(), false));
1611        }
1612
1613        let matches_key = |entry: &(RowKey, ScanRow)| entry.0.as_bytes() == partition_key;
1614
1615        // Multiple candidate generations may hold the same partition; reconcile
1616        // with the same authoritative k-way merge the full scan uses (write-support
1617        // only), TARGETED to just this partition. Issue #2096: SEEK each candidate
1618        // directly to the target partition's `Data.db` offset (BTI trie / Index.db)
1619        // and reconcile through the partition-seeking merger (issues #2207/#2346)
1620        // instead of the full-scan `KWayMerger::new`, which decoded every partition
1621        // with token <= the target before reaching it (O(partitions-below-target)).
1622        // The seeking merge reconciles through the SAME `KWayMerger`
1623        // (`from_row_iterators`), so its output is byte-identical to the former
1624        // full-merge-then-`retain(matches_key)`, only over O(target) work.
1625        #[cfg(all(feature = "write-support", not(feature = "tombstones")))]
1626        if candidates.len() > 1 {
1627            if let Some(schema) = schema {
1628                let target = RowKey::new(partition_key.to_vec());
1629                match generation_merge::seek_merge_generations_for_read(
1630                    &candidates,
1631                    schema,
1632                    &target,
1633                )
1634                .await
1635                {
1636                    Ok(merged) => {
1637                        // Work-counter gate (Issue #958): the k-way merge parsed
1638                        // every surviving candidate, and `merged` (already the target
1639                        // partition only) is exactly the partitions this lookup returns.
1640                        work_counters::add_sstables_scanned(candidates.len() as u64);
1641                        work_counters::add_partitions_parsed(merged.len() as u64);
1642                        // The cross-generation merge decodes full partitions; the
1643                        // clustering seek does not engage here (#954). Correct rows
1644                        // via the post-scan backstop; honest non-engaged signal.
1645                        return Ok((merged, false));
1646                    }
1647                    Err(e) => {
1648                        tracing::warn!(
1649                            "SSTableManager::scan_partition - cross-generation merge failed for \
1650                             '{}' ({}); falling back to per-reader concatenation",
1651                            table_id,
1652                            e
1653                        );
1654                    }
1655                }
1656            }
1657        }
1658
1659        // Single candidate (the common case): SEEK directly to the partition's
1660        // Data.db offset and decode ONLY that partition (Issue #953), instead of a
1661        // full parse-then-retain. The seek resolves the offset via the BTI trie /
1662        // Index.db and runs the same per-partition decode the scan path uses, so
1663        // its output is byte-for-byte identical to `scan(...).retain(matches_key)`.
1664        // If the seek is not applicable for this reader (no authoritative offset,
1665        // or an unsupported format), it returns `Ok(None)` and we FALL BACK to the
1666        // full scan + retain for that candidate (Constraint #4: correctness over
1667        // optimization). The multi-candidate concat fallback below is unchanged —
1668        // only the single-candidate path gets the seek.
1669        let mut all_results = Vec::new();
1670        let mut clustering_engaged = false;
1671        for reader in &candidates {
1672            // Work-counter gate (Issue #958): one real Data.db touch per surviving
1673            // candidate. Counted here (not at prune time) so the counter reflects
1674            // SSTables actually opened/scanned, the cost a regression would balloon.
1675            work_counters::add_sstables_scanned(1);
1676
1677            let mut results = if candidates.len() == 1 {
1678                // Issue #954: thread the clustering slice into the seek so it can
1679                // narrow the within-partition decode via the authoritative row
1680                // index. `engaged` records whether the clustering narrowing
1681                // actually bounded the decode (vs a full-partition decode).
1682                match reader
1683                    .scan_single_partition_clustering(
1684                        table_id,
1685                        partition_key,
1686                        clustering,
1687                        fully_qualified_match,
1688                        schema,
1689                    )
1690                    .await
1691                {
1692                    // Seek resolved authoritatively: use its rows directly. They
1693                    // already match exactly this partition's key, so no retain.
1694                    Ok(Some((rows, engaged))) => {
1695                        clustering_engaged = engaged;
1696                        rows
1697                    }
1698                    // Seek not applicable (Constraint #4): full scan + retain.
1699                    Ok(None) => {
1700                        let mut r = reader.scan(table_id, None, None, None, schema).await?;
1701                        r.retain(matches_key);
1702                        r
1703                    }
1704                    Err(e) => return Err(e),
1705                }
1706            } else {
1707                // Multi-candidate concat fallback (merge unavailable): preserve the
1708                // existing full-scan + retain behaviour per candidate (Constraint #2).
1709                let mut r = reader.scan(table_id, None, None, None, schema).await?;
1710                r.retain(matches_key);
1711                r
1712            };
1713            all_results.append(&mut results);
1714        }
1715        // #1917: rows here already share `partition_key` (prior retain) so this is a no-op today; kept for future multi-partition safety + parity with the IN fan-out / streaming scan's token order (raw-byte = #1580 wrong-order).
1716        if candidates.len() > 1 {
1717            all_results.sort_by(|a, b| cmp_partition_keys_by_token(a.0.as_bytes(), b.0.as_bytes()));
1718        }
1719        work_counters::add_partitions_parsed(all_results.len() as u64);
1720        Ok((all_results, clustering_engaged))
1721    }
1722
1723    /// `tombstones`-build counterpart of [`scan_partition`](Self::scan_partition).
1724    ///
1725    /// That build uses a structurally different reader map and has no bloom-prune
1726    /// `scan_partition` path, so a fully-constrained `WHERE pk = ?` is served by
1727    /// scanning and filtering to the partition key. The output is a subset of
1728    /// [`scan`](Self::scan) — identical to what the `not(tombstones)`
1729    /// `scan_partition` returns — which keeps the query executor free of any
1730    /// `tombstones` cfg branching.
1731    ///
1732    /// Returns `(rows, engaged)` with `engaged == false`: this is a full scan +
1733    /// retain with NO SSTable prune, so the caller MUST report an honest fallback
1734    /// access path (`FallbackReason::TombstonesBuildNoPrune`) rather than a targeted
1735    /// label, even though the rows match the pruned build byte-for-byte (Epic #951).
1736    #[cfg(feature = "tombstones")]
1737    pub async fn scan_partition(
1738        &self,
1739        table_id: &TableId,
1740        partition_key: &[u8],
1741        schema: Option<&crate::schema::TableSchema>,
1742    ) -> Result<(Vec<(RowKey, ScanRow)>, bool)> {
1743        let mut rows = self.scan(table_id, None, None, None, schema).await?;
1744        rows.retain(|entry| entry.0.as_bytes() == partition_key);
1745        Ok((rows, false))
1746    }
1747
1748    /// Resolve the reader list for a table id, trying the fully-qualified
1749    /// `keyspace.table` name first and falling back to the bare table name, so
1750    /// same-named tables in different keyspaces stay distinct (Issue #680).
1751    ///
1752    /// Shared by [`get`](Self::get), [`scan`](Self::scan), and
1753    /// [`scan_partition`](Self::scan_partition) so the resolution rule lives in
1754    /// one place and the targeted-lookup path can never drift from `scan`.
1755    pub(in crate::storage::sstable) fn resolve_reader_list<'a>(
1756        table_readers: &'a HashMap<String, Vec<Arc<reader::SSTableReader>>>,
1757        table_name: &str,
1758    ) -> Option<&'a Vec<Arc<reader::SSTableReader>>> {
1759        if let Some(list) = table_readers.get(table_name) {
1760            return Some(list);
1761        }
1762        let unqualified = table_name
1763            .rfind('.')
1764            .map_or(table_name, |dot| &table_name[dot + 1..]);
1765        table_readers.get(unqualified)
1766    }
1767
1768    /// Authoritative resolution-mode signal that gates the BTI point-lookup
1769    /// table-consistency guard (issue #1321, mirroring the seek path #1284).
1770    ///
1771    /// Returns `true` iff the queried `table_name` matched the fully-qualified
1772    /// `table_readers` map EXACTLY (or is unqualified, so has no keyspace to
1773    /// mismatch), and `false` iff a fully-qualified `keyspace.table` query can
1774    /// only have reached a reader via the bare-name fallback. Only an exact FQ
1775    /// match may relax across a benign header-keyspace divergence; a fallback
1776    /// keeps strict keyspace matching so `get()` never surfaces another
1777    /// keyspace's same-named rows.
1778    ///
1779    /// Shared verbatim by BOTH `get()` builds (the `tombstones` and the default
1780    /// `not(tombstones)` managers) so the relaxation is identical in every
1781    /// feature build — the single source of truth for the wiring.
1782    pub(in crate::storage::sstable) fn fully_qualified_match(
1783        table_readers: &HashMap<String, Vec<Arc<reader::SSTableReader>>>,
1784        table_name: &str,
1785    ) -> bool {
1786        !table_name.contains('.') || table_readers.contains_key(table_name)
1787    }
1788
1789    /// Scan a table and return per-cell write metadata alongside row values.
1790    ///
1791    /// Used when `ProjectionFlags::include_cell_metadata` is set (issue #693 — the
1792    /// WRITETIME/TTL threading bridge).  Delegates to each reader's
1793    /// `scan_with_cell_metadata`.  When multiple readers serve the same table the
1794    /// results are concatenated; token-order sort and LIMIT are applied afterward.
1795    ///
1796    /// Falls back to the regular `scan` with empty metadata when the reader does not
1797    /// surface metadata (non-V5CompressedLegacy paths).
1798    ///
1799    /// Issue #1535: this is the single implementation for both the default /
1800    /// `write-support` build and the `tombstones` build. The former `tombstones`
1801    /// variant delegated to `scan` and returned empty metadata, so `WRITETIME(col)`
1802    /// / `TTL(col)` wrongly resolved to null under `--features tombstones`. The
1803    /// reader-level `scan_with_cell_metadata` surfaces the authoritative per-cell
1804    /// timestamp/TTL regardless of feature flags, so the fix is simply to use it in
1805    /// both builds. The cross-generation metadata merge stays gated on `write-support`
1806    /// (it needs the `KWayMerger`); without it the per-reader concatenation still
1807    /// surfaces each cell's real metadata rather than fabricating or dropping it.
1808    pub async fn scan_with_cell_metadata(
1809        &self,
1810        table_id: &TableId,
1811        start_key: Option<&RowKey>,
1812        end_key: Option<&RowKey>,
1813        limit: Option<usize>,
1814        schema: Option<&crate::schema::TableSchema>,
1815    ) -> Result<Vec<(RowKey, ScanRow, HashMap<String, CellWriteMetadata>)>> {
1816        // Issue #1591: snapshot the reader list and DROP the read guard before any
1817        // I/O (per-reader metadata decode and cross-generation merge).
1818        let (reader_list, _fully_qualified_match) = self.resolve_reader_snapshot(table_id).await;
1819        if reader_list.is_empty() {
1820            return Ok(Vec::new());
1821        }
1822
1823        // Issue #885: the metadata path (WRITETIME/TTL projection) must
1824        // reconcile across SSTable generations exactly like the plain `scan`
1825        // path (#883) — otherwise a multi-generation directory returns
1826        // duplicate rows and resurrects rows/cells deleted in a later
1827        // generation. Drive the same authoritative k-way merger, then surface
1828        // the WINNING cell's per-cell write timestamp / TTL (write-support
1829        // only; requires schema). Single-generation reads skip this entirely.
1830        #[cfg(feature = "write-support")]
1831        if reader_list.len() > 1 {
1832            if let Some(schema) = schema {
1833                match generation_merge::merge_generations_for_read_with_metadata(
1834                    &reader_list,
1835                    schema,
1836                    start_key,
1837                    end_key,
1838                    limit,
1839                    None,
1840                )
1841                .await
1842                {
1843                    Ok(merged) => return Ok(merged),
1844                    Err(e) => {
1845                        // Never fail a read because the merge path hit an
1846                        // unsupported format; fall back to concatenation.
1847                        tracing::warn!(
1848                            "SSTableManager::scan_with_cell_metadata - cross-generation merge \
1849                             failed for '{}' ({}); falling back to per-reader concatenation",
1850                            table_id,
1851                            e
1852                        );
1853                    }
1854                }
1855            }
1856        }
1857
1858        // K-way merge the per-reader token-ordered streams (issue #1580),
1859        // mirroring `scan`: the metadata payload rides alongside the row.
1860        // Merging in token order (not raw key bytes) matches Cassandra's
1861        // cross-SSTable order and avoids the full O(n log n) re-sort.
1862        let mut per_reader = Vec::with_capacity(reader_list.len());
1863        for reader in &reader_list {
1864            let results = reader
1865                .scan_with_cell_metadata(table_id, start_key, end_key, None, schema)
1866                .await?;
1867            per_reader.push(
1868                results
1869                    .into_iter()
1870                    .map(|(k, v, m)| (k, (v, m)))
1871                    .collect::<Vec<_>>(),
1872            );
1873        }
1874
1875        let all_results = scan_merge::kway_merge_token_order(per_reader, limit)
1876            .into_iter()
1877            .map(|(k, (v, m))| (k, v, m))
1878            .collect();
1879
1880        Ok(all_results)
1881    }
1882
1883    /// Snapshot the resolved reader set for `table_id` and DROP the
1884    /// `table_readers` read guard before returning (issue #1591).
1885    ///
1886    /// Returns the cloned `Arc<SSTableReader>` handles serving the table plus the
1887    /// authoritative `fully_qualified_match` signal (see
1888    /// [`fully_qualified_match`](Self::fully_qualified_match)), acquiring the read
1889    /// guard only long enough to clone the small `Vec` of `Arc`s.
1890    ///
1891    /// # Why (tail-latency fix)
1892    ///
1893    /// `tokio::sync::RwLock` is FIFO-fair: a single queued writer (reader reload,
1894    /// schema set, generation removal) parks EVERY later-arriving reader behind
1895    /// the longest in-flight read guard. Holding the guard across a whole
1896    /// multi-reader scan therefore made one slow scan plus one admin write stall
1897    /// every subsequent point read — bimodal tail latency. Cloning the `Arc` list
1898    /// and releasing the guard immediately means scans and point reads run without
1899    /// holding the lock across their I/O, so a queued writer can never park them.
1900    ///
1901    /// # Semantics (unchanged)
1902    ///
1903    /// A scan operating on this snapshot may miss a reader added to the map AFTER
1904    /// the snapshot was taken. That is the SAME semantics as holding the guard:
1905    /// the guard would have blocked the writer until the scan finished, so the
1906    /// scan never observed the new reader either. Readers removed from the map
1907    /// mid-scan stay alive for the snapshot holder — that is precisely what the
1908    /// `Arc` clone guarantees.
1909    async fn resolve_reader_snapshot(
1910        &self,
1911        table_id: &TableId,
1912    ) -> (Vec<Arc<reader::SSTableReader>>, bool) {
1913        let table_readers = self.table_readers.read().await;
1914        let table_name = table_id.name();
1915        let fully_qualified_match = Self::fully_qualified_match(&table_readers, table_name);
1916        let readers = Self::resolve_reader_list(&table_readers, table_name)
1917            .cloned()
1918            .unwrap_or_default();
1919        // Guard dropped here, before any reader I/O.
1920        (readers, fully_qualified_match)
1921    }
1922
1923    /// Resolve the AUTHORITATIVE partition-key shape for `table_id` from the
1924    /// SSTable readers' Statistics.db SerializationHeader (issue #1750).
1925    ///
1926    /// This is the metadata a schema-less reader DOES have without a CQL schema:
1927    /// the SerializationHeader (surfaced on each reader's `header().columns`)
1928    /// records, per column, the authoritative `is_primary_key` / `is_clustering`
1929    /// flags and the REAL names of the regular + static (non-key) columns. It does
1930    /// NOT record the partition-/clustering-key column NAMES — Cassandra never
1931    /// serialises those (they live in `system_schema`, absent schema-less), so the
1932    /// parser synthesises a placeholder pk name (`build_partition_key_columns`) that
1933    /// MUST NOT be trusted as an identity.
1934    ///
1935    /// Returns [`PartitionKeyShape`] carrying ONLY authoritative facts: the count of
1936    /// partition-key components, the count of clustering keys, and the set of real
1937    /// non-key column names. The caller confirms a predicate column is the sole
1938    /// partition key BY ELIMINATION (single pk component, zero clustering keys, and
1939    /// the column absent from the non-key name set) — never by the synthesised name.
1940    ///
1941    /// The shape is resolved across ALL readers serving the table, not just the
1942    /// first (issue #1750, roborev 3786). A multi-generation / schema-evolved table
1943    /// can add a REGULAR column in a LATER generation that is absent from an earlier
1944    /// generation's header. Consulting only the first reader would MISS that column
1945    /// from the non-key name set, so a `WHERE <later-gen-regular-col> = <val>` could
1946    /// be MISCLASSIFIED as a partition-key seek. To prevent that:
1947    ///   * the non-key column names are UNIONed across every reader's header, and
1948    ///   * the partition-key count, clustering-key count, and the single-component
1949    ///     pk type+name must be CONSISTENT across all readers with a header.
1950    ///
1951    /// Returns `None` when no reader for the table exposes a SerializationHeader OR
1952    /// when the readers' key metadata is INCONSISTENT (a pk-count / clustering-count
1953    /// / single-pk type+name disagreement — which should never happen for one table
1954    /// but, if it did, means we cannot trust the shape). Both are fail-safe: the
1955    /// caller then keeps the honest full-scan path (all from SerializationHeaders,
1956    /// no heuristics — #28).
1957    pub async fn partition_key_shape(&self, table_id: &TableId) -> Option<PartitionKeyShape> {
1958        let (readers, _) = self.resolve_reader_snapshot(table_id).await;
1959        partition_key_shape_from_headers(readers.iter().map(|r| r.header().columns.as_slice()))
1960    }
1961
1962    /// Arm this manager's deterministic scan gate (issue #1591 test only).
1963    /// The next `scan` on this manager pauses at its per-reader I/O, signalling
1964    /// [`Gate::reached`](scan_gate::Gate) and blocking on
1965    /// [`Gate::release`](scan_gate::Gate) until the test lets it continue.
1966    #[cfg(all(test, feature = "write-support", feature = "state_machine"))] // gated to sole caller issue_1591_scan_lock_test; not dead-code under minimal (#1981)
1967    fn arm_scan_gate(&self) -> Arc<scan_gate::Gate> {
1968        let gate = Arc::new(scan_gate::Gate::default());
1969        if let Ok(mut slot) = self.scan_gate.lock() {
1970            *slot = Some(Arc::clone(&gate));
1971        }
1972        gate
1973    }
1974
1975    /// Resolve the readers serving `table_id`, returning cloned `Arc` handles.
1976    ///
1977    /// Mirrors the qualified-then-unqualified lookup of [`scan`](Self::scan)
1978    /// (Issue #680) but yields owned handles so the caller can hold them past the
1979    /// `table_readers` read lock — needed by the streaming scan, which spawns a
1980    /// background merge task.
1981    #[cfg(not(feature = "tombstones"))]
1982    async fn resolve_table_readers(&self, table_id: &TableId) -> Vec<Arc<reader::SSTableReader>> {
1983        let table_readers = self.table_readers.read().await;
1984        let table_name = table_id.name();
1985        let list = if table_readers.contains_key(table_name) {
1986            table_readers.get(table_name)
1987        } else {
1988            let unqualified_name = if let Some(dot_pos) = table_name.rfind('.') {
1989                &table_name[dot_pos + 1..]
1990            } else {
1991                table_name
1992            };
1993            table_readers.get(unqualified_name)
1994        };
1995        list.cloned().unwrap_or_default()
1996    }
1997
1998    /// Reports whether [`scan_stream`](Self::scan_stream) PRE-MATERIALIZES the
1999    /// full reconciled result for this table before returning the channel, rather
2000    /// than yielding rows lazily (issue #1577, roborev round-4).
2001    ///
2002    /// A bounded LIMIT consumer of `scan_stream` (see
2003    /// [`SelectExecutor::capped_fallback_scan`](crate::query)) may drop the stream
2004    /// once `cap` rows arrive to stop the producer decoding the tail — but that
2005    /// decode-stop win, and the per-received-row `QUERY_ROWS_SCANNED` accounting it
2006    /// implies, are ONLY valid when the stream is genuinely lazy. When this returns
2007    /// `true`, the storage layer has already decoded EVERY row of the table (the
2008    /// channel is just replaying a materialized `Vec`), so the caller must charge
2009    /// the FULL decoded row count to the scan-work metric and take a materializing
2010    /// accounting path instead.
2011    ///
2012    /// The condition mirrors EXACTLY the materializing branches `scan_stream`
2013    /// takes, so the two can never disagree:
2014    /// * the `tombstones` build's `scan_stream` delegates wholesale to the
2015    ///   materializing [`scan`](Self::scan) — always `true`;
2016    /// * any BTI (`da`) reader: `run_scan_stream`'s BTI branch (issue #1577) drives
2017    ///   the trie-walk `bti_scan_with_metadata`, which fully materializes the
2018    ///   (index-less) reconciled table before streaming — so a bounded consumer
2019    ///   never decode-stops for a BTI table, regardless of generation count;
2020    /// * the default (non-`tombstones`) build's `write-support` cross-generation
2021    ///   branch is LAZY since #1579 (streams via
2022    ///   `generation_merge::stream_generations_for_read`), so a bounded consumer
2023    ///   DOES decode-stop there — that branch is NOT reported as materializing.
2024    #[cfg(feature = "tombstones")]
2025    pub async fn scan_stream_materializes(
2026        &self,
2027        _table_id: &TableId,
2028        _schema: Option<&crate::schema::TableSchema>,
2029    ) -> bool {
2030        // The `tombstones` build's `scan_stream` forwards a fully-materialized
2031        // `scan` result, so a bounded consumer never decode-stops.
2032        true
2033    }
2034
2035    /// See the `tombstones` variant above.
2036    #[cfg(not(feature = "tombstones"))]
2037    pub async fn scan_stream_materializes(
2038        &self,
2039        table_id: &TableId,
2040        _schema: Option<&crate::schema::TableSchema>,
2041    ) -> bool {
2042        let readers = self.resolve_table_readers(table_id).await;
2043
2044        // Issue #1577: any BTI (`da`) reader makes `scan_stream` pre-materialize
2045        // (the trie-walk BTI branch decodes the whole reconciled table before
2046        // streaming), so a bounded LIMIT consumer must charge the full decoded
2047        // count — the exact condition `run_scan_stream` gates its BTI branch on
2048        // (`bti_partitions_db.is_some()`, surfaced as `reader.is_bti()`).
2049        if readers.iter().any(|r| r.is_bti()) {
2050            return true;
2051        }
2052
2053        // Issue #1579 / D3 (roborev job 3669, Medium): the `not(tombstones)`
2054        // write-support multi-generation branch NO LONGER pre-materializes. Since
2055        // #1579 `scan_stream` routes the cross-generation case through the LAZY,
2056        // backpressure-bounded `generation_merge::stream_generations_for_read`
2057        // (`KWayMerger` driven on a blocking task, live rows fed STRAIGHT into the
2058        // bounded channel) — NOT the materializing `merge_generations_for_read`. The
2059        // merger reconciles every generation for a partition at the moment its key is
2060        // stepped (LWW + tombstone shadowing applied before emission) and emits
2061        // partitions in strictly increasing `(token, key)` order, so a bounded LIMIT
2062        // consumer can decode-stop after the authoritative first `cap` rows. Returning
2063        // `false` restores D1's decode-stop for multi-gen LIMIT and makes the
2064        // per-received-row `QUERY_ROWS_SCANNED` accounting MORE accurate (only ~`cap`
2065        // rows decoded). BTI is still handled above (`true`) — its own branch
2066        // materializes regardless of generation count.
2067        false
2068    }
2069
2070    /// Streaming scan (issue #790): merge per-SSTable streams lazily into a
2071    /// bounded output channel, in key (token) order, without materializing the
2072    /// whole result.
2073    ///
2074    /// Each reader yields entries already in token order; a k-way merge over the
2075    /// per-reader heads produces globally ordered output while holding only one
2076    /// pending entry per SSTable. Live heap is bounded by `buffer_size` plus the
2077    /// number of SSTables, independent of total row count — the streaming analog
2078    /// of the materializing [`scan`](Self::scan) (concat + stable sort by key).
2079    ///
2080    /// # Multi-generation correctness (Issue #957)
2081    ///
2082    /// The lazy per-reader k-way merge above is only the streaming analog of
2083    /// `scan`'s **concat + sort** path, which is correct for a single generation.
2084    /// When a table directory holds more than one SSTable generation, the same
2085    /// `(partition, clustering)` row can live in several generations and a
2086    /// row/cell tombstone in a newer generation suppresses only its own
2087    /// generation's copy — so a pure key-ordered merge would emit overwritten
2088    /// rows twice and resurrect rows deleted in a later generation. `scan` avoids
2089    /// this by routing the multi-generation case through the authoritative
2090    /// `KWayMerger` (the same LWW + tombstone-shadowing k-way merge compaction
2091    /// uses); this streaming path must reconcile identically or `execute()` and
2092    /// `execute_streaming()` diverge.
2093    ///
2094    /// # Streaming the multi-generation merge (Issue #1579 / D3)
2095    ///
2096    /// The multi-generation case runs
2097    /// [`generation_merge::stream_generations_for_read`], which drives that same
2098    /// `KWayMerger` on a blocking task and feeds each stepped partition's live
2099    /// rows STRAIGHT into the bounded channel via `blocking_send` (backpressure
2100    /// preserved) — instead of collecting the ENTIRE reconciled table, sorting it,
2101    /// and only then dribbling it (the pre-D3 behaviour). Live heap is therefore
2102    /// O(one partition + channel), and time-to-first-row is O(first partition),
2103    /// not O(full merge). The merger already emits partitions in `(token, key)`
2104    /// order, byte-identical to `scan`'s `sort_by_token_order`, so the emitted
2105    /// order is unchanged (issue #1579 ordering guardrail). On a merger
2106    /// CONSTRUCTION error the driver reports back so this path FALLS BACK to the
2107    /// lazy per-reader streaming merge, mirroring `scan`'s fall-back-to-concat. The
2108    /// single-generation / no-schema / no-`write-support` cases keep the lazy
2109    /// streaming merge, which already matches `scan`'s concat path exactly and
2110    /// preserves LIMIT/backpressure.
2111    #[cfg(not(feature = "tombstones"))]
2112    pub async fn scan_stream(
2113        &self,
2114        table_id: &TableId,
2115        start_key: Option<&RowKey>,
2116        end_key: Option<&RowKey>,
2117        schema: Option<&crate::schema::TableSchema>,
2118        buffer_size: usize,
2119    ) -> Result<tokio::sync::mpsc::Receiver<Result<(RowKey, ScanRow)>>> {
2120        let readers = self.resolve_table_readers(table_id).await;
2121
2122        // Issue #957: keep the materializing `scan` and this streaming path
2123        // definitionally in lockstep. Reuse the EXACT guard `scan` uses for
2124        // cross-generation reconciliation (`reader_list.len() > 1 && schema present`,
2125        // write-support only) and the same merger, then forward the reconciled rows
2126        // through the streaming channel. Without this, a partition spread across
2127        // generations duplicates overwritten rows and resurrects deleted ones in the
2128        // stream while `scan` returns the merged, deduplicated, tombstone-honouring
2129        // result.
2130        #[cfg(feature = "write-support")]
2131        if readers.len() > 1 {
2132            // No schema: cross-generation LWW/tombstone reconciliation needs the
2133            // schema to drive the KWayMerger, so this deliberately falls through to
2134            // the lazy per-reader token-merge below — matching `scan`'s identical
2135            // no-schema fallback (both accept the documented Issue #883 concat
2136            // limitation rather than reconciling without a schema).
2137            if let Some(schema) = schema {
2138                match generation_merge::stream_generations_for_read(
2139                    &readers,
2140                    schema,
2141                    start_key,
2142                    end_key,
2143                    buffer_size,
2144                )
2145                .await
2146                {
2147                    Ok(rx) => {
2148                        tracing::debug!(
2149                            "SSTableManager::scan_stream - cross-generation merge streaming \
2150                             (O(window), not materialized)"
2151                        );
2152                        return Ok(rx);
2153                    }
2154                    Err(e) => {
2155                        // Never fail a read because the merge could not be
2156                        // constructed (unsupported format); fall back to the lazy
2157                        // streaming merge, matching `scan`'s fall-back-to-concatenation.
2158                        //
2159                        // Error-path asymmetry, intentional (issue #1579): this `Err`
2160                        // is ONLY the merger-CONSTRUCTION failure (`KWayMerger::new`,
2161                        // signalled back before any row is streamed), so falling back
2162                        // here can never emit a partially-merged, mis-reconciled
2163                        // result — the streaming task has not sent anything yet. A
2164                        // `step()` error occurring MID-merge (after some partitions
2165                        // were already streamed to the caller) is NOT retried here;
2166                        // it is delivered downstream as an `Err` item on the output
2167                        // channel (see `stream_generations_for_read`), ending the
2168                        // stream at that point. That is deliberately SAFER than the
2169                        // materializing `scan`'s fallback, which — if it could fail
2170                        // partway through populating its `Vec` — would have no way to
2171                        // signal "these rows are reconciled, the rest are not"; the
2172                        // streaming channel's `Err` item gives the caller an honest,
2173                        // unambiguous cutoff instead of silently returning a
2174                        // half-reconciled table.
2175                        tracing::warn!(
2176                            "SSTableManager::scan_stream - cross-generation merge failed for '{}' ({}); \
2177                             falling back to lazy per-reader streaming merge",
2178                            table_id,
2179                            e
2180                        );
2181                    }
2182                }
2183            }
2184        }
2185
2186        let (out_tx, out_rx) = tokio::sync::mpsc::channel(buffer_size.max(1));
2187
2188        // Own everything the background merge task needs.
2189        let table_id = table_id.clone();
2190        let start_key = start_key.cloned();
2191        let end_key = end_key.cloned();
2192        let schema = schema.cloned();
2193
2194        tokio::spawn(async move {
2195            // Admission control (issue #1594, F4): this fan-out merge is ONE
2196            // top-level scan OPERATION that legitimately needs all N per-generation
2197            // sub-scans live AT ONCE (it primes a head from every sub-scan before
2198            // draining any). Acquire exactly ONE admission permit here, for the
2199            // whole operation, and open each sub-scan `Exempt` (below) so the
2200            // sub-scans do NOT each independently admit. If they did, a fan-out to
2201            // `N > cap` generations would deadlock: `cap` sub-scans would win
2202            // permits and park in backpressure while the rest blocked forever at
2203            // `admit`, and this priming loop — waiting on the blocked sub-scans —
2204            // would never drain the permit-holders. Held via this RAII guard for
2205            // the whole merge; released on every exit. See `scan_admission` docs.
2206            let _admission =
2207                crate::storage::sstable::reader::scan_stream_windowed::scan_admission::admit()
2208                    .await;
2209
2210            // Open one streaming scan per reader. Each is `Exempt` — the single
2211            // permit above covers the whole fan-out operation (issue #1594).
2212            let mut streams: Vec<tokio::sync::mpsc::Receiver<Result<(RowKey, ScanRow)>>> = readers
2213                .into_iter()
2214                .map(|reader| {
2215                    reader.scan_stream_admitted(
2216                        table_id.clone(),
2217                        start_key.clone(),
2218                        end_key.clone(),
2219                        schema.clone(),
2220                        buffer_size,
2221                        crate::storage::sstable::reader::scan_stream_windowed::scan_admission::ScanAdmission::Exempt,
2222                    )
2223                })
2224                .collect();
2225
2226            // Prime one head per stream. Each head carries its precomputed
2227            // Cassandra Murmur3 token so the merge orders by (token, key) — the
2228            // authoritative cross-SSTable order (issue #1580) — and never hashes a
2229            // key more than once. Comparing by raw `RowKey` bytes here (as this
2230            // path previously did) diverged from `scan`'s token order.
2231            let token_of = |key: &RowKey| {
2232                crate::util::cassandra_murmur3::cassandra_murmur3_token(key.as_bytes())
2233            };
2234            let mut heads: Vec<Option<(i64, RowKey, ScanRow)>> = Vec::with_capacity(streams.len());
2235            for stream in streams.iter_mut() {
2236                match stream.recv().await {
2237                    Some(Ok((key, row))) => heads.push(Some((token_of(&key), key, row))),
2238                    Some(Err(e)) => {
2239                        let _ = out_tx.send(Err(e)).await;
2240                        return;
2241                    }
2242                    None => heads.push(None),
2243                }
2244            }
2245
2246            // K-way merge: repeatedly emit the head with the smallest
2247            // (token, key), ties broken by reader index to match the stable
2248            // token-order merge of `scan`.
2249            loop {
2250                let mut min_idx: Option<usize> = None;
2251                for (i, head) in heads.iter().enumerate() {
2252                    if let Some((ref token, ref key, _)) = head {
2253                        match min_idx {
2254                            None => min_idx = Some(i),
2255                            Some(m) => {
2256                                if let Some((ref min_token, ref min_key, _)) = heads[m] {
2257                                    if (token, key) < (min_token, min_key) {
2258                                        min_idx = Some(i);
2259                                    }
2260                                }
2261                            }
2262                        }
2263                    }
2264                }
2265                let idx = match min_idx {
2266                    Some(idx) => idx,
2267                    None => break, // all streams exhausted
2268                };
2269
2270                // Take the winning entry and advance only that stream.
2271                let entry = match heads[idx].take() {
2272                    Some((_, key, row)) => (key, row),
2273                    None => break, // unreachable: min_idx points to a Some head
2274                };
2275                match streams[idx].recv().await {
2276                    Some(Ok((key, row))) => heads[idx] = Some((token_of(&key), key, row)),
2277                    Some(Err(e)) => {
2278                        let _ = out_tx.send(Err(e)).await;
2279                        return;
2280                    }
2281                    None => {} // stream exhausted; head stays None
2282                }
2283
2284                if out_tx.send(Ok(entry)).await.is_err() {
2285                    return; // consumer dropped
2286                }
2287            }
2288        });
2289
2290        Ok(out_rx)
2291    }
2292
2293    /// Batched streaming scan (issue #1592, Epic F/F2): the additive companion to
2294    /// [`scan_stream`](Self::scan_stream) whose channel item is a `Vec` BATCH of
2295    /// entries. It forwards one batch per async wake instead of one row, undoing
2296    /// the per-row re-flattening on the public channel that the internal windowed
2297    /// pipeline (issue #1143) was built to avoid.
2298    ///
2299    /// Content + order are identical to [`scan_stream`](Self::scan_stream):
2300    /// flattening the batches reproduces the per-row stream exactly.
2301    ///
2302    /// - **Single generation** (one reader): the reader's windowed batches are
2303    ///   forwarded STRAIGHT THROUGH — no per-row channel is interposed, so the
2304    ///   wake amortization survives end to end (the F2 win).
2305    /// - **Zero / multiple generations**: reuses the fully-correct per-row
2306    ///   [`scan_stream`](Self::scan_stream) (cross-generation reconciliation +
2307    ///   token-ordered k-way merge + empty case) and re-chunks its output into
2308    ///   batches for the public channel. A generation-aware fully-batched merge is
2309    ///   a deliberate follow-up (audit §Epic F); cross-generation correctness wins
2310    ///   here.
2311    #[cfg(not(feature = "tombstones"))]
2312    pub async fn scan_stream_batched(
2313        &self,
2314        table_id: &TableId,
2315        start_key: Option<&RowKey>,
2316        end_key: Option<&RowKey>,
2317        schema: Option<&crate::schema::TableSchema>,
2318        buffer_size: usize,
2319    ) -> Result<tokio::sync::mpsc::Receiver<Result<Vec<(RowKey, ScanRow)>>>> {
2320        let readers = self.resolve_table_readers(table_id).await;
2321
2322        if readers.len() == 1 {
2323            if let Some(reader) = readers.into_iter().next() {
2324                return Ok(reader.scan_stream_batched(
2325                    table_id.clone(),
2326                    start_key.cloned(),
2327                    end_key.cloned(),
2328                    schema.cloned(),
2329                    buffer_size,
2330                ));
2331            }
2332        }
2333
2334        let per_row = self
2335            .scan_stream(table_id, start_key, end_key, schema, buffer_size)
2336            .await?;
2337        Ok(Self::rechunk_into_batches(per_row, buffer_size))
2338    }
2339
2340    /// Re-chunk a per-row streaming receiver into `BATCH_EMIT_ROWS`-sized `Vec`
2341    /// batches over a bounded channel (issue #1592). Preserves order and content
2342    /// exactly (FIFO push/flush) and preserves backpressure: the batch channel is
2343    /// bounded, so a stalled consumer stops the drain of the per-row source, which
2344    /// stops the upstream scan. The trailing partial batch is flushed at end. A
2345    /// mid-stream error is forwarded as a terminal item.
2346    ///
2347    /// Used for the zero/multi-generation and `tombstones` cases, where a
2348    /// straight-through single-reader hand-off is not applicable (the per-row
2349    /// source is a k-way merge / materialized reconciliation, not one reader).
2350    fn rechunk_into_batches(
2351        mut per_row: tokio::sync::mpsc::Receiver<Result<(RowKey, ScanRow)>>,
2352        buffer_size: usize,
2353    ) -> tokio::sync::mpsc::Receiver<Result<Vec<(RowKey, ScanRow)>>> {
2354        use reader::scan_stream_windowed::BATCH_EMIT_ROWS;
2355        // Bound the batch channel so its resident-row budget stays comparable to
2356        // the per-row surface's `buffer_size`, not `buffer_size * BATCH_EMIT_ROWS`.
2357        let cap = buffer_size.div_ceil(BATCH_EMIT_ROWS).max(1);
2358        let (tx, rx) = tokio::sync::mpsc::channel(cap);
2359        tokio::spawn(async move {
2360            let mut batch: Vec<(RowKey, ScanRow)> = Vec::with_capacity(BATCH_EMIT_ROWS);
2361            while let Some(item) = per_row.recv().await {
2362                match item {
2363                    Ok(entry) => {
2364                        batch.push(entry);
2365                        if batch.len() >= BATCH_EMIT_ROWS {
2366                            if tx.send(Ok(std::mem::take(&mut batch))).await.is_err() {
2367                                return; // consumer dropped
2368                            }
2369                            batch.reserve(BATCH_EMIT_ROWS);
2370                        }
2371                    }
2372                    Err(e) => {
2373                        // Flush already-received Ok rows BEFORE surfacing the
2374                        // error, to match the per-row `scan_stream` guarantee
2375                        // that confirmed rows are delivered ahead of a terminal
2376                        // error (issue #1143 / #1592). Dropping them here would
2377                        // silently lose up to BATCH_EMIT_ROWS-1 rows.
2378                        if !batch.is_empty() {
2379                            let _ = tx.send(Ok(std::mem::take(&mut batch))).await;
2380                        }
2381                        let _ = tx.send(Err(e)).await;
2382                        return;
2383                    }
2384                }
2385            }
2386            if !batch.is_empty() {
2387                let _ = tx.send(Ok(batch)).await;
2388            }
2389        });
2390        rx
2391    }
2392
2393    /// Streaming scan under the `tombstones` feature.
2394    ///
2395    /// Streaming the cross-generation tombstone merge is not yet implemented, so
2396    /// this falls back to the materializing [`scan`](Self::scan) and forwards the
2397    /// result through a bounded channel. The public API stays uniform across
2398    /// feature configs; the O(rows) memory win of issue #790 applies only to the
2399    /// default (non-`tombstones`) build.
2400    #[cfg(feature = "tombstones")]
2401    pub async fn scan_stream(
2402        &self,
2403        table_id: &TableId,
2404        start_key: Option<&RowKey>,
2405        end_key: Option<&RowKey>,
2406        schema: Option<&crate::schema::TableSchema>,
2407        buffer_size: usize,
2408    ) -> Result<tokio::sync::mpsc::Receiver<Result<(RowKey, ScanRow)>>> {
2409        let results = self
2410            .scan(table_id, start_key, end_key, None, schema)
2411            .await?;
2412        let (tx, rx) = tokio::sync::mpsc::channel(buffer_size.max(1));
2413        tokio::spawn(async move {
2414            for entry in results {
2415                if tx.send(Ok(entry)).await.is_err() {
2416                    break; // consumer dropped
2417                }
2418            }
2419        });
2420        Ok(rx)
2421    }
2422
2423    /// Batched streaming scan under the `tombstones` feature (issue #1592).
2424    ///
2425    /// Tombstone builds reconcile via the materializing [`scan`](Self::scan)
2426    /// inside [`scan_stream`](Self::scan_stream); this re-chunks that per-row
2427    /// stream into `Vec` batches. Unlike the default build there is no
2428    /// single-reader straight-through path — a straight-through hand-off would
2429    /// bypass the cross-generation tombstone reconciliation. Content and order
2430    /// match [`scan_stream`](Self::scan_stream) exactly.
2431    #[cfg(feature = "tombstones")]
2432    pub async fn scan_stream_batched(
2433        &self,
2434        table_id: &TableId,
2435        start_key: Option<&RowKey>,
2436        end_key: Option<&RowKey>,
2437        schema: Option<&crate::schema::TableSchema>,
2438        buffer_size: usize,
2439    ) -> Result<tokio::sync::mpsc::Receiver<Result<Vec<(RowKey, ScanRow)>>>> {
2440        let per_row = self
2441            .scan_stream(table_id, start_key, end_key, schema, buffer_size)
2442            .await?;
2443        Ok(Self::rechunk_into_batches(per_row, buffer_size))
2444    }
2445
2446    /// Get list of all SSTable IDs
2447    pub async fn list_sstables(&self) -> Vec<SSTableId> {
2448        let readers = self.readers.read().await;
2449        readers.keys().cloned().collect()
2450    }
2451
2452    /// Remove an SSTable
2453    pub async fn remove_sstable(&self, sstable_id: &SSTableId) -> Result<()> {
2454        // Remove from memory
2455        {
2456            let mut readers = self.readers.write().await;
2457            if let Some(removed) = readers.remove(sstable_id) {
2458                // Issue #2059 §C: drop the removed generation's process-global
2459                // key-cache entries (distinct `invalidations` counter).
2460                removed.invalidate_key_cache_entries();
2461            }
2462        }
2463
2464        // Delete file
2465        let file_path = self.base_path.join(sstable_id.filename());
2466        if self.platform.fs().exists(&file_path).await? {
2467            self.platform.fs().remove_file(&file_path).await?;
2468        }
2469
2470        Ok(())
2471    }
2472
2473    /// Get SSTable statistics
2474    pub async fn stats(&self) -> Result<SSTableStats> {
2475        let readers = self.readers.read().await;
2476
2477        let mut total_size = 0u64;
2478        let mut total_entries = 0u64;
2479        let mut total_tables = 0u64;
2480        let sstable_count = readers.len();
2481
2482        for reader in readers.values() {
2483            let reader_stats = reader.stats().await?;
2484            total_size += reader_stats.file_size;
2485            total_entries += reader_stats.entry_count;
2486            total_tables += reader_stats.table_count;
2487        }
2488
2489        Ok(SSTableStats {
2490            sstable_count,
2491            total_size,
2492            total_entries,
2493            total_tables,
2494            average_size: if sstable_count > 0 {
2495                total_size / sstable_count as u64
2496            } else {
2497                0
2498            },
2499        })
2500    }
2501
2502    /// Set the schema registry for schema-aware operations
2503    ///
2504    /// This method stores the schema registry and applies it to all existing SSTable readers.
2505    /// Future readers loaded via `load_existing_sstables` or `load_from_table_directories`
2506    /// will also receive the schema registry during creation.
2507    #[cfg(feature = "state_machine")]
2508    pub async fn set_schema_registry(
2509        &self,
2510        registry: Arc<RwLock<crate::schema::SchemaRegistry>>,
2511    ) -> Result<()> {
2512        // Store the schema registry
2513        {
2514            let mut schema_reg = self.schema_registry.write().await;
2515            *schema_reg = Some(registry.clone());
2516        }
2517
2518        // Apply to all existing readers
2519        // Note: SSTableReader::set_schema_registry requires &mut self, but readers are Arc<SSTableReader>
2520        // This is by design - schema should be set during reader creation, not after.
2521        // The stored registry will be applied to future readers loaded by this manager.
2522
2523        // For existing readers, we cannot mutate them directly since they're behind Arc.
2524        // The schema registry will be applied to new readers as they're loaded.
2525
2526        Ok(())
2527    }
2528
2529    /// Merge multiple SSTables into a new one
2530    ///
2531    /// NOTE: SSTable writing removed in Issue #176 (writer.rs deleted).
2532    /// This method is feature-gated behind 'experimental' but currently unimplemented.
2533    #[cfg(feature = "experimental")]
2534    pub async fn merge_sstables(
2535        &self,
2536        _source_ids: Vec<SSTableId>,
2537        _target_id: SSTableId,
2538    ) -> Result<()> {
2539        Err(crate::error::Error::unsupported_format(
2540            "SSTable merging removed in Issue #176 - writer.rs deleted",
2541        ))
2542    }
2543
2544    #[cfg(not(feature = "experimental"))]
2545    pub async fn merge_sstables(
2546        &self,
2547        _source_ids: Vec<SSTableId>,
2548        _target_id: SSTableId,
2549    ) -> Result<()> {
2550        Err(crate::error::Error::unsupported_format(
2551            "SSTable merging requires experimental feature",
2552        ))
2553    }
2554}
2555
2556/// SSTable statistics
2557#[derive(Debug, Clone)]
2558pub struct SSTableStats {
2559    /// Number of SSTable files
2560    pub sstable_count: usize,
2561
2562    /// Total size of all SSTables in bytes
2563    pub total_size: u64,
2564
2565    /// Total number of entries across all SSTables
2566    pub total_entries: u64,
2567
2568    /// Total number of tables across all SSTables
2569    pub total_tables: u64,
2570
2571    /// Average SSTable size in bytes
2572    pub average_size: u64,
2573}
2574
2575#[cfg(test)]
2576mod tests {
2577    use super::*;
2578    use crate::platform::Platform;
2579    use tempfile::TempDir;
2580
2581    #[tokio::test]
2582    async fn test_sstable_manager_creation() {
2583        let temp_dir = TempDir::new().unwrap();
2584        let config = Config::default();
2585        let platform = Arc::new(Platform::new(&config).await.unwrap());
2586
2587        let manager = SSTableManager::new(
2588            temp_dir.path(),
2589            &config,
2590            platform,
2591            #[cfg(feature = "state_machine")]
2592            None,
2593        )
2594        .await
2595        .unwrap();
2596        let stats = manager.stats().await.unwrap();
2597
2598        assert_eq!(stats.sstable_count, 0);
2599        assert_eq!(stats.total_size, 0);
2600    }
2601
2602    #[tokio::test]
2603    async fn test_sstable_manager_from_discovered_paths_empty() {
2604        let temp_dir = TempDir::new().unwrap();
2605        let config = Config::default();
2606        let platform = Arc::new(Platform::new(&config).await.unwrap());
2607
2608        // Create an empty list of discovered paths
2609        let discovered_paths = Vec::new();
2610
2611        let manager = SSTableManager::new_from_discovered_paths(
2612            temp_dir.path(),
2613            discovered_paths,
2614            &config,
2615            platform,
2616            #[cfg(feature = "state_machine")]
2617            None,
2618        )
2619        .await
2620        .unwrap();
2621
2622        let stats = manager.stats().await.unwrap();
2623
2624        // Should have 0 SSTables since we provided an empty list
2625        assert_eq!(stats.sstable_count, 0);
2626        assert_eq!(stats.total_size, 0);
2627    }
2628
2629    #[tokio::test]
2630    async fn test_sstable_manager_from_discovered_paths_with_directories() {
2631        use std::fs;
2632
2633        let temp_dir = TempDir::new().unwrap();
2634        let config = Config::default();
2635        let platform = Arc::new(Platform::new(&config).await.unwrap());
2636
2637        // Create mock table directories with Data.db files
2638        let keyspace_dir = temp_dir.path().join("test_ks");
2639        fs::create_dir(&keyspace_dir).unwrap();
2640
2641        let table1_dir = keyspace_dir.join("users-abc123");
2642        fs::create_dir(&table1_dir).unwrap();
2643        // Note: These are mock files that won't parse as real SSTables,
2644        // but they test the directory scanning logic
2645        fs::write(table1_dir.join("na-1-big-Data.db"), b"mock_data").unwrap();
2646
2647        let table2_dir = keyspace_dir.join("posts-def456");
2648        fs::create_dir(&table2_dir).unwrap();
2649        fs::write(table2_dir.join("na-2-big-Data.db"), b"mock_data").unwrap();
2650        fs::write(table2_dir.join("na-3-big-Data.db"), b"mock_data").unwrap();
2651
2652        // Provide table directories to manager
2653        let table_dirs = vec![table1_dir.clone(), table2_dir.clone()];
2654
2655        let manager = SSTableManager::new_from_discovered_paths(
2656            temp_dir.path(),
2657            table_dirs,
2658            &config,
2659            platform,
2660            #[cfg(feature = "state_machine")]
2661            None,
2662        )
2663        .await
2664        .unwrap();
2665
2666        let stats = manager.stats().await.unwrap();
2667
2668        // VG3 update: `na-*-big-*` files are now correctly identified as BIG-format
2669        // headerless SSTables (VersionGates::Big(_)), so the SSTableManager can open
2670        // them with a minimal header even if the data content is invalid mock bytes.
2671        // The exact sstable_count depends on whether opening succeeds (it creates a
2672        // minimal header) or fails (if the mock bytes cause a deeper parse error).
2673        // We only assert the manager itself was created successfully (no panic/error).
2674        // The directory scanning logic is validated by the successful manager creation.
2675        let _ = stats.sstable_count; // count may be 0 or 3 depending on parse depth
2676    }
2677
2678    #[tokio::test]
2679    #[ignore = "M3+ feature; gated for M1"]
2680    async fn test_sstable_id_generation() {
2681        let id1 = SSTableId::new();
2682        let id2 = SSTableId::new();
2683
2684        assert_ne!(id1.filename(), id2.filename());
2685        assert!(id1.filename().starts_with("sstable_"));
2686        assert!(id1.filename().ends_with(".sst"));
2687    }
2688
2689    /// Regression test for Issue #481: `._*` AppleDouble sidecars must not be
2690    /// returned by `find_data_files`.
2691    ///
2692    /// Before the fix, `find_data_files` only checked `ends_with("-Data.db")`,
2693    /// so `._nb-1-big-Data.db` passed the filter and would later fail to open
2694    /// as a valid SSTable.  The test would fail on the pre-fix code because
2695    /// `results` would contain two paths instead of one.
2696    #[tokio::test]
2697    async fn test_find_data_files_excludes_apple_double_sidecar() {
2698        use std::fs;
2699
2700        let temp_dir = tempfile::TempDir::new().unwrap();
2701        let config = Config::default();
2702        let platform = Arc::new(Platform::new(&config).await.unwrap());
2703
2704        // Write a minimal (invalid but correctly named) SSTable file and its
2705        // macOS AppleDouble sidecar companion alongside it.
2706        let real_file = temp_dir.path().join("nb-1-big-Data.db");
2707        let sidecar = temp_dir.path().join("._nb-1-big-Data.db");
2708        fs::write(&real_file, b"\x00").unwrap();
2709        fs::write(&sidecar, b"\x00\x00").unwrap();
2710
2711        // find_data_files scans `temp_dir` with max_depth=0 (single level).
2712        let results = SSTableManager::find_data_files(&platform, temp_dir.path(), 0)
2713            .await
2714            .unwrap();
2715
2716        // Only the real Data.db file should be returned; the ._ sidecar must be excluded.
2717        assert_eq!(
2718            results.len(),
2719            1,
2720            "expected exactly 1 result but got {}: {:?}",
2721            results.len(),
2722            results
2723        );
2724        assert_eq!(results[0], real_file);
2725        assert!(
2726            !results.contains(&sidecar),
2727            "AppleDouble sidecar must not appear in results"
2728        );
2729    }
2730
2731    /// Unit test for the is_apple_double_sidecar helper.
2732    #[test]
2733    fn test_is_apple_double_sidecar() {
2734        // Must match
2735        assert!(is_apple_double_sidecar("._nb-1-big-Data.db"));
2736        assert!(is_apple_double_sidecar("._anything"));
2737        assert!(is_apple_double_sidecar("._"));
2738        // Must not match
2739        assert!(!is_apple_double_sidecar("nb-1-big-Data.db"));
2740        assert!(!is_apple_double_sidecar("na-2-big-Data.db"));
2741        assert!(!is_apple_double_sidecar(""));
2742    }
2743
2744    #[test]
2745    fn test_extract_table_name() {
2746        use std::path::PathBuf;
2747
2748        // Test standard Cassandra table directory format
2749        let path =
2750            PathBuf::from("test-data/datasets/sstables/test_basic/simple_table-6aa08200a25111f0a3fef1a551383fb9/nb-1-big-Data.db");
2751        assert_eq!(extract_table_name(&path), Some("simple_table".to_string()));
2752
2753        // Test table name with hyphens
2754        let path = PathBuf::from(
2755            "test-data/datasets/sstables/test_basic/my-test-table-6aa08200a25111f0a3fef1a551383fb9/nb-1-big-Data.db",
2756        );
2757        assert_eq!(extract_table_name(&path), Some("my-test-table".to_string()));
2758
2759        // Test multi_partition_table
2760        let path = PathBuf::from(
2761            "test-data/datasets/sstables/test_basic/multi_partition_table-6ac52100a25111f0a3fef1a551383fb9/nb-1-big-Data.db",
2762        );
2763        assert_eq!(
2764            extract_table_name(&path),
2765            Some("multi_partition_table".to_string())
2766        );
2767
2768        // Test compression_test_table
2769        let path = PathBuf::from(
2770            "test-data/datasets/sstables/test_basic/compression_test_table-6ad6ad30a25111f0a3fef1a551383fb9/nb-1-big-Data.db",
2771        );
2772        assert_eq!(
2773            extract_table_name(&path),
2774            Some("compression_test_table".to_string())
2775        );
2776
2777        // Test edge case: directory without UUID
2778        let path =
2779            PathBuf::from("test-data/datasets/sstables/test_basic/simple_table/nb-1-big-Data.db");
2780        assert_eq!(extract_table_name(&path), Some("simple_table".to_string()));
2781
2782        // Test edge case: no parent directory
2783        let path = PathBuf::from("nb-1-big-Data.db");
2784        assert_eq!(extract_table_name(&path), None);
2785    }
2786
2787    /// Issue #1321: the resolution-mode signal that BOTH `get()` builds thread
2788    /// into the BTI point-lookup guard is the single shared helper
2789    /// `SSTableManager::fully_qualified_match`. This compiles and runs under EVERY
2790    /// feature build (incl. `tombstones`/`--all-features`), so it pins that the
2791    /// tombstones-build manager `get()` is wired to the SAME relaxation as the
2792    /// default build — the gap roborev flagged was the wiring, not the guard.
2793    ///
2794    ///   - exact FQ match present in the map → relax (`true`);
2795    ///   - FQ query absent (would reach a reader only via the bare-name fallback)
2796    ///     → strict (`false`), so no wrong-keyspace rows;
2797    ///   - unqualified query → exact match (`true`), no keyspace to mismatch.
2798    #[test]
2799    fn test_fully_qualified_match_signal_both_builds() {
2800        let mut table_readers: HashMap<String, Vec<Arc<reader::SSTableReader>>> = HashMap::new();
2801        table_readers.insert("ks_a.users".to_string(), Vec::new());
2802
2803        // Exact fully-qualified key present → relax (the #1321 acceptance signal).
2804        assert!(
2805            SSTableManager::fully_qualified_match(&table_readers, "ks_a.users"),
2806            "exact FQ map hit must signal an exact match (relax)"
2807        );
2808
2809        // Fully-qualified query whose exact key is ABSENT (resolution could only
2810        // succeed via the bare-name fallback) → strict, so the per-row guard keeps
2811        // strict keyspace matching and never surfaces ks_a's rows for a ks_b query.
2812        assert!(
2813            !SSTableManager::fully_qualified_match(&table_readers, "ks_b.users"),
2814            "FQ query missing its exact key must signal a fallback (strict)"
2815        );
2816
2817        // Unqualified query has no keyspace to mismatch → treated as exact match.
2818        assert!(
2819            SSTableManager::fully_qualified_match(&table_readers, "users"),
2820            "unqualified query must signal an exact match (relax)"
2821        );
2822    }
2823
2824    /// Open a real `SSTableReader` from the dataset for `keyspace.table`, or
2825    /// `None` if datasets are not present (so the test can skip in CI lanes
2826    /// without binaries). Used to obtain distinct `Arc<SSTableReader>` objects
2827    /// for the cross-keyspace bleed test below.
2828    async fn open_dataset_reader(
2829        keyspace: &str,
2830        table: &str,
2831    ) -> Option<Arc<reader::SSTableReader>> {
2832        let datasets_root = std::env::var("CQLITE_DATASETS_ROOT").ok()?;
2833        let keyspace_dir = PathBuf::from(datasets_root).join("sstables").join(keyspace);
2834        let table_prefix = format!("{}-", table);
2835        for entry in std::fs::read_dir(&keyspace_dir).ok()?.flatten() {
2836            let path = entry.path();
2837            let file_name = path.file_name()?.to_str()?.to_string();
2838            if file_name.starts_with(&table_prefix) {
2839                let data_file = std::fs::read_dir(&path)
2840                    .ok()?
2841                    .flatten()
2842                    .find(|e| {
2843                        e.file_name()
2844                            .to_str()
2845                            .map(|s| s.ends_with("-Data.db"))
2846                            .unwrap_or(false)
2847                    })?
2848                    .path();
2849                let config = Config::default();
2850                let platform = Arc::new(Platform::new(&config).await.ok()?);
2851                return reader::SSTableReader::open(&data_file, &config, platform)
2852                    .await
2853                    .ok()
2854                    .map(Arc::new);
2855            }
2856        }
2857        None
2858    }
2859
2860    /// Issue #1321 (roborev HIGH, cross-keyspace bleed): the `tombstones`-build
2861    /// manager `get()` builds its tombstone-merge set from `resolve_reader_list`
2862    /// (the resolved target table across generations) rather than iterating EVERY
2863    /// reader in `self.readers`. This pins the bleed-prevention invariant at the
2864    /// reader-set-resolution level: a fully-qualified query for `ks_a.users`
2865    /// resolves to a merge set containing ONLY the `ks_a.users` reader and NEVER
2866    /// the same-named `ks_b.users` reader.
2867    ///
2868    /// This would FAIL against the pre-fix b469818e behavior, where `get()`
2869    /// iterated `self.readers` (which holds BOTH readers) and — because the
2870    /// global relaxed `fully_qualified_match` flag was `true` (the FQ key existed)
2871    /// — admitted the wrong-keyspace reader's rows/tombstones into the merge.
2872    ///
2873    /// Uses two distinct real readers as the two keyspaces' SSTables; skips when
2874    /// datasets are absent (CI lanes without binaries).
2875    #[tokio::test]
2876    async fn test_tombstones_get_resolves_only_target_keyspace_readers() {
2877        // Two distinct on-disk readers stand in for same-named tables in two
2878        // different keyspaces (only their distinct identity matters here).
2879        let Some(reader_a) = open_dataset_reader("test_basic", "simple_table").await else {
2880            eprintln!("skipping: CQLITE_DATASETS_ROOT / test_basic.simple_table absent");
2881            return;
2882        };
2883        let Some(reader_b) = open_dataset_reader("test_basic", "counters").await else {
2884            eprintln!("skipping: CQLITE_DATASETS_ROOT / test_basic.counters absent");
2885            return;
2886        };
2887        assert!(
2888            !Arc::ptr_eq(&reader_a, &reader_b),
2889            "the two stand-in keyspace readers must be distinct Arcs"
2890        );
2891
2892        // Register them as same-named tables under two distinct keyspaces — the
2893        // exact `table_readers` layout that produced the bleed (Issue #680 keying).
2894        let mut table_readers: HashMap<String, Vec<Arc<reader::SSTableReader>>> = HashMap::new();
2895        table_readers.insert("ks_a.users".to_string(), vec![Arc::clone(&reader_a)]);
2896        table_readers.insert("ks_b.users".to_string(), vec![Arc::clone(&reader_b)]);
2897
2898        // The merge set the new tombstones get() iterates: ONLY ks_a's readers.
2899        let resolved = SSTableManager::resolve_reader_list(&table_readers, "ks_a.users")
2900            .expect("ks_a.users resolves");
2901        assert_eq!(
2902            resolved.len(),
2903            1,
2904            "merge set must be exactly ks_a's readers"
2905        );
2906        assert!(
2907            Arc::ptr_eq(&resolved[0], &reader_a),
2908            "resolved merge set must contain the ks_a reader"
2909        );
2910        // The bleed assertion: the ks_b (wrong-keyspace) reader must NEVER be in
2911        // the ks_a merge set — even though `self.readers` (which the old code
2912        // iterated) contained it and the FQ flag was relaxed.
2913        assert!(
2914            !resolved.iter().any(|r| Arc::ptr_eq(r, &reader_b)),
2915            "Issue #1321: a ks_a.users query must NOT merge the same-named ks_b.users reader"
2916        );
2917
2918        // And the relaxation signal stays correct for the FQ query (exact hit).
2919        assert!(
2920            SSTableManager::fully_qualified_match(&table_readers, "ks_a.users"),
2921            "exact FQ key present → relaxed guard, applied only to the resolved (ks_a) set"
2922        );
2923    }
2924
2925    /// Resolve the on-disk table directory (the one holding `*-Data.db`) for
2926    /// `keyspace.table`, or `None` when datasets are absent (CI-lane skip).
2927    ///
2928    /// Skip keys off the `-Data.db` binary being present, NOT merely the table
2929    /// directory existing: a clean worktree ships the JSONL references (so the
2930    /// directory exists) but not the gitignored `-Data.db` binaries. Returning
2931    /// `Some` for a binary-less directory would open zero readers and make the
2932    /// caller's `readers.is_empty()` assertion PANIC instead of skip. Keeping the
2933    /// gate on the binary preserves "present fixture that yields 0 readers is a
2934    /// hard failure" (issue #2065, same doctrine as #1860).
2935    fn dataset_table_dir(keyspace: &str, table: &str) -> Option<PathBuf> {
2936        let datasets_root = std::env::var("CQLITE_DATASETS_ROOT").ok()?;
2937        let keyspace_dir = PathBuf::from(datasets_root).join("sstables").join(keyspace);
2938        let table_prefix = format!("{}-", table);
2939        for entry in std::fs::read_dir(&keyspace_dir).ok()?.flatten() {
2940            let path = entry.path();
2941            let file_name = path.file_name()?.to_str()?.to_string();
2942            if path.is_dir() && file_name.starts_with(&table_prefix) && dir_has_data_db(&path) {
2943                return Some(path);
2944            }
2945        }
2946        None
2947    }
2948
2949    /// True if `dir` holds a `*-Data.db` binary (the gitignored fixture data),
2950    /// as opposed to only JSONL references. See `dataset_table_dir`.
2951    fn dir_has_data_db(dir: &Path) -> bool {
2952        let Ok(entries) = std::fs::read_dir(dir) else {
2953            return false;
2954        };
2955        entries
2956            .flatten()
2957            .any(|f| f.file_name().to_string_lossy().ends_with("-Data.db"))
2958    }
2959
2960    /// Issue #2059: since the key cache is now a SINGLE process-global instance
2961    /// shared by every reader (not a per-reader cache summed over the open set),
2962    /// `aggregate_key_cache_stats` reports that one global cache's live snapshot —
2963    /// its capacity is the fixed GLOBAL budget reported ONCE, INDEPENDENT of how many
2964    /// readers a manager has open (the whole point of the global bound: no
2965    /// `N_readers × per_reader_cap` growth). A manager with block caching disabled
2966    /// reports honest zeros instead of another database's global activity.
2967    #[tokio::test]
2968    async fn test_global_key_cache_capacity_is_reader_count_independent() {
2969        // Two distinct real table directories → several readers open at once; skip
2970        // when datasets are absent.
2971        let Some(dir_a) = dataset_table_dir("test_basic", "simple_table") else {
2972            eprintln!("skipping: CQLITE_DATASETS_ROOT / test_basic.simple_table absent");
2973            return;
2974        };
2975        let Some(dir_b) = dataset_table_dir("test_basic", "counters") else {
2976            eprintln!("skipping: CQLITE_DATASETS_ROOT / test_basic.counters absent");
2977            return;
2978        };
2979
2980        let config = Config::default();
2981        let platform = Arc::new(Platform::new(&config).await.unwrap());
2982        let storage_path = dir_a.parent().map(PathBuf::from).unwrap_or(dir_a.clone());
2983        let manager = SSTableManager::new_from_discovered_paths(
2984            &storage_path,
2985            vec![dir_a, dir_b],
2986            &config,
2987            platform,
2988            #[cfg(feature = "state_machine")]
2989            None,
2990        )
2991        .await
2992        .unwrap();
2993
2994        let distinct = {
2995            let readers = manager.readers.read().await;
2996            let table_readers = manager.table_readers.read().await;
2997            assert!(!readers.is_empty(), "fixture present but no readers by-id");
2998            let mut seen: std::collections::HashSet<*const reader::SSTableReader> =
2999                std::collections::HashSet::new();
3000            for r in readers.values().chain(table_readers.values().flatten()) {
3001                seen.insert(Arc::as_ptr(r));
3002            }
3003            seen.len()
3004        };
3005
3006        // The reported capacity is the fixed GLOBAL budget, reported once — NOT
3007        // multiplied by the (multiple) open readers.
3008        let agg = manager.aggregate_key_cache_stats().await;
3009        assert_eq!(
3010            agg.capacity_bytes,
3011            crate::storage::cache::DEFAULT_GLOBAL_KEY_CACHE_BYTES,
3012            "global cache reports its single fixed budget regardless of reader count \
3013             ({distinct} readers open)"
3014        );
3015
3016        // A block-cache-disabled manager reports honest zeros (not the global cache's
3017        // live numbers) — the disabled toggle genuinely suppresses the surface.
3018        let mut disabled_config = Config::default();
3019        disabled_config.memory.block_cache.enabled = false;
3020        let dplatform = Arc::new(Platform::new(&disabled_config).await.unwrap());
3021        let dmanager = SSTableManager::new(
3022            &storage_path,
3023            &disabled_config,
3024            dplatform,
3025            #[cfg(feature = "state_machine")]
3026            None,
3027        )
3028        .await
3029        .unwrap();
3030        let dagg = dmanager.aggregate_key_cache_stats().await;
3031        assert_eq!(
3032            dagg.capacity_bytes, 0,
3033            "disabled manager reports zero capacity"
3034        );
3035        assert_eq!(dagg.hits, 0);
3036        assert_eq!(dagg.misses, 0);
3037    }
3038
3039    /// Issue #1592 (roborev Finding 1): a MID-STREAM error must NOT drop the
3040    /// already-received `Ok` rows accumulated in the pending batch. `rechunk_into_batches`
3041    /// (the zero/multi-generation + `tombstones` forwarder) must flush the pending
3042    /// rows BEFORE forwarding the terminal `Err`, matching the per-row `scan_stream`
3043    /// guarantee (issue #1143) that confirmed rows are delivered ahead of the error.
3044    #[tokio::test]
3045    async fn rechunk_flushes_pending_rows_before_midstream_error() {
3046        use reader::scan_stream_windowed::BATCH_EMIT_ROWS;
3047
3048        // Feed FEWER than BATCH_EMIT_ROWS Ok rows (so nothing auto-flushes and they
3049        // all sit in the pending batch), then a mid-stream Err.
3050        let pending = 3usize;
3051        assert!(pending < BATCH_EMIT_ROWS);
3052        let (in_tx, in_rx) = tokio::sync::mpsc::channel::<Result<(RowKey, ScanRow)>>(16);
3053        for i in 0..pending {
3054            let key = RowKey::new(vec![i as u8]);
3055            let row = ScanRow::RawRow(vec![i as u8]);
3056            in_tx.send(Ok((key, row))).await.unwrap();
3057        }
3058        in_tx
3059            .send(Err(crate::Error::Corruption("boom".to_string())))
3060            .await
3061            .unwrap();
3062        drop(in_tx); // close the source
3063
3064        let mut out = SSTableManager::rechunk_into_batches(in_rx, 64);
3065
3066        // First item: the flushed pending batch with ALL confirmed rows, in order.
3067        let first = out
3068            .recv()
3069            .await
3070            .expect("expected a pending batch before the error");
3071        let batch = first.expect("pending batch must be Ok, not the error");
3072        assert_eq!(
3073            batch.len(),
3074            pending,
3075            "all confirmed rows must survive the error"
3076        );
3077        assert!(
3078            batch.len() <= BATCH_EMIT_ROWS,
3079            "batch must respect the BATCH_EMIT_ROWS bound"
3080        );
3081        for (i, (key, _row)) in batch.iter().enumerate() {
3082            assert_eq!(key.as_bytes(), [i as u8], "rows must arrive in order");
3083        }
3084
3085        // Second item: the terminal error, AFTER the confirmed rows.
3086        let second = out.recv().await.expect("expected the terminal error item");
3087        assert!(second.is_err(), "second item must be the forwarded error");
3088
3089        // Stream then ends.
3090        assert!(
3091            out.recv().await.is_none(),
3092            "no items after the terminal error"
3093        );
3094    }
3095
3096    // ---- partition_key_shape_from_headers: multi-generation union (roborev 3786) ----
3097
3098    /// Build a minimal `ColumnInfo` for the shape-resolution unit tests.
3099    fn col(
3100        name: &str,
3101        cql_type: &str,
3102        is_primary_key: bool,
3103        is_clustering: bool,
3104    ) -> crate::parser::header::ColumnInfo {
3105        crate::parser::header::ColumnInfo {
3106            name: name.to_string(),
3107            column_type: cql_type.to_string(),
3108            is_primary_key,
3109            key_position: None,
3110            is_static: false,
3111            is_clustering,
3112            clustering_reversed: false,
3113        }
3114    }
3115
3116    /// roborev 3786: a REGULAR column present ONLY in a LATER generation must land in
3117    /// the UNIONed non-key name set, so a `WHERE <later-gen-regular-col> = <val>` is
3118    /// NOT misclassified as a partition-key seek. Two headers, single-component `int`
3119    /// pk, zero clustering: gen-1 has `v1`, gen-2 adds `v2`.
3120    #[test]
3121    fn partition_key_shape_unions_later_generation_regular_column() {
3122        let gen1 = vec![
3123            col("partition_key", "int", true, false),
3124            col("v1", "text", false, false),
3125        ];
3126        let gen2 = vec![
3127            col("partition_key", "int", true, false),
3128            col("v1", "text", false, false),
3129            col("v2", "text", false, false),
3130        ];
3131        let shape = partition_key_shape_from_headers([gen1.as_slice(), gen2.as_slice()])
3132            .expect("consistent headers must resolve a shape");
3133        assert_eq!(shape.partition_key_count, 1);
3134        assert_eq!(shape.clustering_key_count, 0);
3135        // The later-generation regular column IS in the unioned non-key set — so the
3136        // classifier's by-elimination check will NOT treat it as the partition key.
3137        assert!(
3138            shape.non_key_column_names.contains("v2"),
3139            "later-gen regular column must be in the unioned non-key set (roborev 3786)",
3140        );
3141        assert!(shape.non_key_column_names.contains("v1"));
3142        assert_eq!(
3143            shape
3144                .single_pk_component
3145                .as_ref()
3146                .map(|c| c.cql_type.as_str()),
3147            Some("int"),
3148        );
3149
3150        // The misclassification the fix prevents: had we used only gen-1, `v2` would
3151        // be absent from the non-key set and thus admitted by elimination as the pk.
3152        let gen1_only =
3153            partition_key_shape_from_headers([gen1.as_slice()]).expect("single header resolves");
3154        assert!(
3155            !gen1_only.non_key_column_names.contains("v2"),
3156            "gen-1 alone MISSES v2 — this is exactly the multi-gen bug the union fixes",
3157        );
3158    }
3159
3160    /// Inconsistent key metadata across readers (a pk-count disagreement) declines
3161    /// the shape → `None` → the caller full-scans (fail-safe, no heuristics).
3162    #[test]
3163    fn partition_key_shape_declines_inconsistent_readers() {
3164        // gen-1: single-component pk; gen-2: composite (2-component) pk.
3165        let single = vec![
3166            col("pk", "int", true, false),
3167            col("v", "text", false, false),
3168        ];
3169        let composite = vec![
3170            col("pk_a", "int", true, false),
3171            col("pk_b", "int", true, false),
3172            col("v", "text", false, false),
3173        ];
3174        assert_eq!(
3175            partition_key_shape_from_headers([single.as_slice(), composite.as_slice()]),
3176            None,
3177            "a pk-count disagreement across readers must decline (→ full-scan)",
3178        );
3179
3180        // A clustering-count disagreement likewise declines.
3181        let clustered = vec![
3182            col("pk", "int", true, false),
3183            col("ck", "int", true, true),
3184            col("v", "text", false, false),
3185        ];
3186        assert_eq!(
3187            partition_key_shape_from_headers([single.as_slice(), clustered.as_slice()]),
3188            None,
3189            "a clustering-count disagreement across readers must decline",
3190        );
3191
3192        // A single-pk TYPE disagreement declines too.
3193        let single_bigint = vec![
3194            col("pk", "bigint", true, false),
3195            col("v", "text", false, false),
3196        ];
3197        assert_eq!(
3198            partition_key_shape_from_headers([single.as_slice(), single_bigint.as_slice()]),
3199            None,
3200            "a single-pk type disagreement across readers must decline",
3201        );
3202    }
3203
3204    /// No header with a partition-key column → `None` (fail-safe). No readers, or an
3205    /// empty header, or a header exposing no pk column all decline (never a zero-key
3206    /// shape).
3207    #[test]
3208    fn partition_key_shape_none_without_usable_header() {
3209        assert_eq!(partition_key_shape_from_headers(std::iter::empty()), None);
3210        let empty: Vec<crate::parser::header::ColumnInfo> = vec![];
3211        assert_eq!(partition_key_shape_from_headers([empty.as_slice()]), None);
3212
3213        // A populated header that names no partition-key column is not a usable pk
3214        // shape either → decline (complete-union fail-safe, roborev 3788).
3215        let no_pk = vec![col("v", "text", false, false)];
3216        assert_eq!(partition_key_shape_from_headers([no_pk.as_slice()]), None);
3217    }
3218
3219    /// COMPLETE-UNION fail-safe (roborev 3788): if ANY resolved reader lacks a usable
3220    /// header, the union of non-key names is INCOMPLETE, so we cannot prove a column is
3221    /// the pk by its absence. One headerless reader among several usable ones ⇒ decline
3222    /// (`None`) so the classifier full-scans instead of misclassifying a regular column
3223    /// (present only in the headerless reader) as a partition-key seek.
3224    #[test]
3225    fn partition_key_shape_declines_when_any_reader_lacks_usable_header() {
3226        let usable = vec![
3227            col("partition_key", "int", true, false),
3228            col("v1", "text", false, false),
3229        ];
3230        let headerless: Vec<crate::parser::header::ColumnInfo> = vec![];
3231
3232        // Headerless reader FIRST among several.
3233        assert_eq!(
3234            partition_key_shape_from_headers([headerless.as_slice(), usable.as_slice()]),
3235            None,
3236            "a leading headerless reader must decline the whole shape (→ full-scan)",
3237        );
3238        // Headerless reader LAST — the union is still incomplete, still declines.
3239        assert_eq!(
3240            partition_key_shape_from_headers([usable.as_slice(), headerless.as_slice()]),
3241            None,
3242            "a trailing headerless reader must decline the whole shape (→ full-scan)",
3243        );
3244        // Headerless reader BETWEEN two usable ones.
3245        let usable2 = vec![
3246            col("partition_key", "int", true, false),
3247            col("v1", "text", false, false),
3248            col("v2", "text", false, false),
3249        ];
3250        assert_eq!(
3251            partition_key_shape_from_headers([
3252                usable.as_slice(),
3253                headerless.as_slice(),
3254                usable2.as_slice(),
3255            ]),
3256            None,
3257            "any headerless reader among usable ones declines the shape",
3258        );
3259
3260        // Control: the SAME usable readers WITHOUT the headerless one DO resolve — so
3261        // it is specifically the headerless reader that forces the fail-safe decline.
3262        assert!(
3263            partition_key_shape_from_headers([usable.as_slice(), usable2.as_slice()]).is_some(),
3264            "usable readers alone must still resolve (the fix only declines on a gap)",
3265        );
3266    }
3267}