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