Skip to main content

cqlite_core/query/
access_path.rs

1//! Access-path signal for CQL `SELECT` execution (Issue #960, Epic #951).
2//!
3//! # Why this exists
4//!
5//! Correct result rows do **not** prove that a storage-layer prune/seek
6//! capability is actually wired into the CQL execution path. #949 returned the
7//! right rows while still scanning every SSTable and filtering in memory. Tests,
8//! reviewers, and (eventually) `EXPLAIN` output need an *explicit, assertable*
9//! signal for the access path a `SELECT` chose, so that an accidental fallback
10//! to a full scan cannot masquerade as a targeted lookup.
11//!
12//! This module exposes:
13//!
14//! 1. [`AccessPath`] — a closed enum describing the access path a SELECT surface
15//!    selected. Downstream issues (#958 work counters, #962 fast-path
16//!    unification) consume this same enum, so the variant names are part of the
17//!    public contract.
18//! 2. [`FallbackReason`] — a documented closed set of reasons a SELECT fell back
19//!    to a full scan. Recording an *honest* reason here is mandatory: a path that
20//!    still full-scans today MUST report [`AccessPath::FullScan`] or
21//!    [`AccessPath::FallbackFullScan`], never a fake targeted path.
22//! 3. A process-global probe ([`record`], [`last`], [`reset`]) that mirrors the
23//!    `scan_for_key_call_count` pattern (issue #831). Because the streaming
24//!    SELECT path executes inside a spawned task (a different thread), the probe
25//!    is a `Mutex`-guarded global rather than a thread-local — that is the only
26//!    mechanism observable from *both* the materializing and streaming paths and
27//!    from an integration test without parsing logs.
28//!
29//! # Scope (per #960)
30//!
31//! #960 only *exposes and reports* the path; it does not make every path
32//! targeted (that is #962). The reported path must be **honest** — if the
33//! WRITETIME/TTL metadata path still full-scans today, it reports
34//! [`AccessPath::FullScan`], and a test pins that current reality so #962 can
35//! later flip it.
36
37use serde::{Deserialize, Serialize};
38use std::sync::Mutex;
39
40/// The access path a `SELECT` surface selected for a single SSTable-scan step.
41///
42/// This is the public, testable signal. Variant names are a shared contract with
43/// #958 (work counters) and #962 (fast-path unification); do not rename without
44/// updating those consumers and `docs/access-paths.md`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46// snake_case so the serialized tag matches the documented stable `label()` form
47// (e.g. "partition_lookup", "fallback_full_scan") rather than the Rust variant
48// name. Keep this in lockstep with `label()` and `docs/access-paths.md`.
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum AccessPath {
52    /// Every SSTable for the table is scanned and rows are filtered in memory.
53    /// This is the honest baseline when no usable restriction is present.
54    FullScan,
55
56    /// A single, fully-constrained partition (`WHERE pk = ?`) is served by a
57    /// partition-targeted lookup that prunes SSTables (bloom/BTI presence) before
58    /// scanning. Materializing path (#949 fast path).
59    PartitionLookup,
60
61    /// Several fully-constrained partitions (`WHERE pk IN (...)` / token fan-out)
62    /// are served by repeated partition-targeted lookups. Reserved for #955; not
63    /// yet produced by any surface, but named here so #955 can consume it.
64    MultiPartitionLookup,
65
66    /// A partition is targeted and a clustering-key predicate
67    /// (`WHERE pk = ? AND ck </>/= ?`) prunes rows within the partition. Reserved
68    /// for #954; not yet produced by any surface.
69    ClusteringSlice,
70
71    /// The WRITETIME/TTL metadata-projection path resolved a single fully
72    /// constrained partition via a targeted lookup. Reserved for #962 — the
73    /// metadata path full-scans today (see [`FallbackReason::MetadataScanPath`]).
74    MetadataPartitionLookup,
75
76    /// The streaming SELECT path served a single fully-constrained partition via a
77    /// partition-targeted lookup (the streaming analogue of [`Self::PartitionLookup`]).
78    StreamingPartitionLookup,
79
80    /// A targeted path was not selected and execution fell back to a full scan.
81    /// The `reason` is from the documented closed set [`FallbackReason`], so an
82    /// accidental fallback cannot look like a targeted success.
83    FallbackFullScan {
84        /// Why the targeted path was not taken.
85        reason: FallbackReason,
86    },
87}
88
89impl AccessPath {
90    /// True if this path scans the whole table (full scan or any fallback to one).
91    ///
92    /// Tests use this to assert "this query did NOT do a full scan" without
93    /// matching every fallback reason.
94    pub fn is_full_scan(&self) -> bool {
95        matches!(
96            self,
97            AccessPath::FullScan | AccessPath::FallbackFullScan { .. }
98        )
99    }
100
101    /// True if this path targeted one or more specific partitions rather than
102    /// scanning the whole table.
103    pub fn is_targeted(&self) -> bool {
104        matches!(
105            self,
106            AccessPath::PartitionLookup
107                | AccessPath::MultiPartitionLookup
108                | AccessPath::ClusteringSlice
109                | AccessPath::MetadataPartitionLookup
110                | AccessPath::StreamingPartitionLookup
111        )
112    }
113
114    /// A stable, lowercase label suitable for `EXPLAIN`-style output and JSON.
115    pub fn label(&self) -> &'static str {
116        match self {
117            AccessPath::FullScan => "full_scan",
118            AccessPath::PartitionLookup => "partition_lookup",
119            AccessPath::MultiPartitionLookup => "multi_partition_lookup",
120            AccessPath::ClusteringSlice => "clustering_slice",
121            AccessPath::MetadataPartitionLookup => "metadata_partition_lookup",
122            AccessPath::StreamingPartitionLookup => "streaming_partition_lookup",
123            AccessPath::FallbackFullScan { .. } => "fallback_full_scan",
124        }
125    }
126}
127
128/// The documented, closed set of reasons a `SELECT` fell back to a full scan.
129///
130/// Keep this list in sync with `docs/access-paths.md`. Adding a variant is a
131/// public-contract change: it documents a *new, known* reason a query cannot use
132/// a targeted path. It must never be used to paper over an unexpected fallback —
133/// an unexpected fallback should surface as a failing access-path assertion.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135// snake_case so the serialized form matches the documented stable `label()`
136// (e.g. "metadata_scan_path"). Keep in lockstep with `label()`.
137#[serde(rename_all = "snake_case")]
138#[non_exhaustive]
139pub enum FallbackReason {
140    /// No schema was available, so the partition-key columns cannot be
141    /// identified to build a targeted lookup.
142    NoSchema,
143
144    /// The WHERE clause does not fully constrain the partition key with equality
145    /// (partial key, no restriction, or a non-equality restriction such as a
146    /// range). Mirrors Cassandra's single-partition-read requirement.
147    PartitionKeyNotFullyConstrained,
148
149    /// The fully-constrained partition-key values could not be encoded to the
150    /// on-disk key form (e.g. a type mismatch). A full scan is the safe fallback.
151    PartitionKeyEncodingFailed,
152
153    /// The WRITETIME/TTL metadata-projection path always full-scans today; it
154    /// does not yet route through a partition-targeted lookup. Pinned by tests
155    /// so #962 can flip it to [`AccessPath::MetadataPartitionLookup`].
156    MetadataScanPath,
157
158    /// The legacy `QueryExecutor` (simple-id-lookup and prepared SELECTs) issues
159    /// an unconditional `storage.scan`; it does not consult the fast path.
160    /// Tracked by #962 (route the legacy/prepared surfaces through the modern
161    /// executor) and #961 (param binding).
162    LegacyExecutorPath,
163
164    /// The `tombstones` build compiles out the partition-targeted prune. On that
165    /// build the targeted storage surfaces (`scan_partition`,
166    /// `scan_partition_with_cell_metadata`) are full-scan + retain fallbacks with
167    /// NO bloom/BTI SSTable pruning, so a fully-constrained `WHERE pk = ?` (or
168    /// `IN (...)`/WRITETIME-TTL) opens the whole table even though the rows are
169    /// byte-identical to the pruned build. The executor reports this honest reason
170    /// (rather than a fake targeted label) whenever the storage call returns
171    /// `engaged == false` (Epic #951, honest access paths).
172    TombstonesBuildNoPrune,
173}
174
175impl FallbackReason {
176    /// A stable, lowercase label suitable for `EXPLAIN`-style output and JSON.
177    pub fn label(&self) -> &'static str {
178        match self {
179            FallbackReason::NoSchema => "no_schema",
180            FallbackReason::PartitionKeyNotFullyConstrained => {
181                "partition_key_not_fully_constrained"
182            }
183            FallbackReason::PartitionKeyEncodingFailed => "partition_key_encoding_failed",
184            FallbackReason::MetadataScanPath => "metadata_scan_path",
185            FallbackReason::LegacyExecutorPath => "legacy_executor_path",
186            FallbackReason::TombstonesBuildNoPrune => "tombstones_build_no_prune",
187        }
188    }
189}
190
191impl std::fmt::Display for AccessPath {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        match self {
194            AccessPath::FallbackFullScan { reason } => {
195                write!(f, "{} ({})", self.label(), reason.label())
196            }
197            _ => f.write_str(self.label()),
198        }
199    }
200}
201
202/// Process-global record of the most recent access path selected by a SELECT
203/// SSTable-scan step.
204///
205/// A `Mutex<Option<_>>` rather than a thread-local because the streaming SELECT
206/// path runs inside a spawned task; the probe must be observable from a different
207/// thread than the one that called `execute_streaming`. The lock is taken only on
208/// the cold per-scan decision boundary, so contention is negligible. It is not
209/// gated behind `cfg(test)` because integration tests in `tests/` compile against
210/// the library crate without its `test` cfg (same rationale as
211/// `SCAN_FOR_KEY_CALLS`, issue #831).
212static LAST_ACCESS_PATH: Mutex<Option<AccessPath>> = Mutex::new(None);
213
214/// Record the access path chosen by a SELECT SSTable-scan step.
215///
216/// Called at each decision boundary in the executor. The last call before a test
217/// reads [`last`] reflects the path the query actually took. If the mutex is
218/// poisoned (a prior panic while holding it), the record is silently dropped —
219/// recording is a diagnostic side channel and must never abort a query.
220pub fn record(path: AccessPath) {
221    if let Ok(mut guard) = LAST_ACCESS_PATH.lock() {
222        *guard = Some(path);
223    }
224}
225
226/// Read the most recently recorded access path, or `None` if none was recorded
227/// since the last [`reset`].
228///
229/// Tests assert against this after running a query. Mirrors
230/// `SSTableReader::scan_for_key_call_count` (issue #831).
231pub fn last() -> Option<AccessPath> {
232    LAST_ACCESS_PATH.lock().ok().and_then(|g| g.clone())
233}
234
235/// Clear the recorded access path. Tests call this before a query so a stale
236/// value from a previous query cannot satisfy the assertion.
237pub fn reset() {
238    if let Ok(mut guard) = LAST_ACCESS_PATH.lock() {
239        *guard = None;
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn full_scan_is_not_targeted() {
249        assert!(AccessPath::FullScan.is_full_scan());
250        assert!(!AccessPath::FullScan.is_targeted());
251    }
252
253    #[test]
254    fn fallback_is_full_scan_and_carries_reason() {
255        let p = AccessPath::FallbackFullScan {
256            reason: FallbackReason::NoSchema,
257        };
258        assert!(p.is_full_scan());
259        assert!(!p.is_targeted());
260        assert_eq!(p.to_string(), "fallback_full_scan (no_schema)");
261    }
262
263    #[test]
264    fn partition_lookup_is_targeted() {
265        assert!(AccessPath::PartitionLookup.is_targeted());
266        assert!(!AccessPath::PartitionLookup.is_full_scan());
267        assert_eq!(AccessPath::PartitionLookup.label(), "partition_lookup");
268    }
269
270    #[test]
271    fn serde_tag_matches_label() {
272        // The serialized form must equal the documented stable label(), not the
273        // Rust variant name. A simple variant serializes as a bare JSON string.
274        assert_eq!(
275            serde_json::to_string(&AccessPath::PartitionLookup).unwrap(),
276            "\"partition_lookup\""
277        );
278        assert_eq!(
279            serde_json::to_string(&AccessPath::MetadataPartitionLookup).unwrap(),
280            "\"metadata_partition_lookup\""
281        );
282        // FallbackReason tag also matches its label().
283        assert_eq!(
284            serde_json::to_string(&FallbackReason::MetadataScanPath).unwrap(),
285            "\"metadata_scan_path\""
286        );
287        // Round-trips.
288        let p = AccessPath::FallbackFullScan {
289            reason: FallbackReason::PartitionKeyNotFullyConstrained,
290        };
291        let json = serde_json::to_string(&p).unwrap();
292        assert_eq!(serde_json::from_str::<AccessPath>(&json).unwrap(), p);
293    }
294
295    #[test]
296    fn probe_round_trips() {
297        reset();
298        assert_eq!(last(), None);
299        record(AccessPath::PartitionLookup);
300        assert_eq!(last(), Some(AccessPath::PartitionLookup));
301        reset();
302        assert_eq!(last(), None);
303    }
304}