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}