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 process-global rather than a thread-local — that is the only mechanism
26//!    observable from *both* the materializing and streaming paths and from an
27//!    integration test without parsing logs. It is stored lock-free via
28//!    `arc_swap::ArcSwapOption` (issue #1595): the signal was written on every
29//!    SELECT, so a `Mutex` here was a process-wide serialization point (and a
30//!    lock-poisoning surface) with no functional need.
31//!
32//! # Scope (per #960)
33//!
34//! #960 only *exposes and reports* the path; it does not make every path
35//! targeted (that is #962 for the single-key metadata lookup and #1916 for the
36//! metadata `IN (...)` fan-out). The reported path must be **honest** — a metadata
37//! projection with no usable restriction still reports an honest
38//! [`AccessPath::FallbackFullScan`], never a faked targeted path.
39
40use arc_swap::ArcSwapOption;
41use serde::{Deserialize, Serialize};
42use std::sync::Arc;
43
44/// The access path a `SELECT` surface selected for a single SSTable-scan step.
45///
46/// This is the public, testable signal. Variant names are a shared contract with
47/// #958 (work counters) and #962 (fast-path unification); do not rename without
48/// updating those consumers and `docs/access-paths.md`.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50// snake_case so the serialized tag matches the documented stable `label()` form
51// (e.g. "partition_lookup", "fallback_full_scan") rather than the Rust variant
52// name. Keep this in lockstep with `label()` and `docs/access-paths.md`.
53#[serde(rename_all = "snake_case")]
54#[non_exhaustive]
55pub enum AccessPath {
56    /// Every SSTable for the table is scanned and rows are filtered in memory.
57    /// This is the honest baseline when no usable restriction is present.
58    FullScan,
59
60    /// A single, fully-constrained partition (`WHERE pk = ?`) is served by a
61    /// partition-targeted lookup that prunes SSTables (bloom/BTI presence) before
62    /// scanning. Materializing path (#949 fast path).
63    PartitionLookup,
64
65    /// Several fully-constrained partitions (`WHERE pk IN (...)` / token fan-out)
66    /// are served by repeated partition-targeted lookups. Reserved for #955; not
67    /// yet produced by any surface, but named here so #955 can consume it.
68    MultiPartitionLookup,
69
70    /// A partition is targeted and a clustering-key predicate
71    /// (`WHERE pk = ? AND ck </>/= ?`) prunes rows within the partition. Reserved
72    /// for #954; not yet produced by any surface.
73    ClusteringSlice,
74
75    /// The WRITETIME/TTL metadata-projection path resolved a single fully
76    /// constrained partition via a targeted lookup (#962). The `WHERE pk IN (...)`
77    /// metadata fan-out reports [`Self::MultiPartitionLookup`] instead (#1916).
78    MetadataPartitionLookup,
79
80    /// The streaming SELECT path served a single fully-constrained partition via a
81    /// partition-targeted lookup (the streaming analogue of [`Self::PartitionLookup`]).
82    StreamingPartitionLookup,
83
84    /// A targeted path was not selected and execution fell back to a full scan.
85    /// The `reason` is from the documented closed set [`FallbackReason`], so an
86    /// accidental fallback cannot look like a targeted success.
87    FallbackFullScan {
88        /// Why the targeted path was not taken.
89        reason: FallbackReason,
90    },
91}
92
93impl AccessPath {
94    /// True if this path scans the whole table (full scan or any fallback to one).
95    ///
96    /// Tests use this to assert "this query did NOT do a full scan" without
97    /// matching every fallback reason.
98    pub fn is_full_scan(&self) -> bool {
99        matches!(
100            self,
101            AccessPath::FullScan | AccessPath::FallbackFullScan { .. }
102        )
103    }
104
105    /// True if this path targeted one or more specific partitions rather than
106    /// scanning the whole table.
107    pub fn is_targeted(&self) -> bool {
108        matches!(
109            self,
110            AccessPath::PartitionLookup
111                | AccessPath::MultiPartitionLookup
112                | AccessPath::ClusteringSlice
113                | AccessPath::MetadataPartitionLookup
114                | AccessPath::StreamingPartitionLookup
115        )
116    }
117
118    /// A stable, lowercase label suitable for `EXPLAIN`-style output and JSON.
119    pub fn label(&self) -> &'static str {
120        match self {
121            AccessPath::FullScan => "full_scan",
122            AccessPath::PartitionLookup => "partition_lookup",
123            AccessPath::MultiPartitionLookup => "multi_partition_lookup",
124            AccessPath::ClusteringSlice => "clustering_slice",
125            AccessPath::MetadataPartitionLookup => "metadata_partition_lookup",
126            AccessPath::StreamingPartitionLookup => "streaming_partition_lookup",
127            AccessPath::FallbackFullScan { .. } => "fallback_full_scan",
128        }
129    }
130}
131
132/// The documented, closed set of reasons a `SELECT` fell back to a full scan.
133///
134/// Keep this list in sync with `docs/access-paths.md`. Adding a variant is a
135/// public-contract change: it documents a *new, known* reason a query cannot use
136/// a targeted path. It must never be used to paper over an unexpected fallback —
137/// an unexpected fallback should surface as a failing access-path assertion.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139// snake_case so the serialized form matches the documented stable `label()`
140// (e.g. "metadata_scan_path"). Keep in lockstep with `label()`.
141#[serde(rename_all = "snake_case")]
142#[non_exhaustive]
143pub enum FallbackReason {
144    /// No schema was available, so the partition-key columns cannot be
145    /// identified to build a targeted lookup.
146    NoSchema,
147
148    /// The WHERE clause does not fully constrain the partition key with equality
149    /// (partial key, no restriction, or a non-equality restriction such as a
150    /// range). Mirrors Cassandra's single-partition-read requirement.
151    PartitionKeyNotFullyConstrained,
152
153    /// The fully-constrained partition-key values could not be encoded to the
154    /// on-disk key form (e.g. a type mismatch). A full scan is the safe fallback.
155    PartitionKeyEncodingFailed,
156
157    /// A WRITETIME/TTL metadata projection with NO usable partition-key
158    /// restriction full-scans (there is no key to target). A fully-constrained
159    /// `WHERE pk = ?` now routes through [`AccessPath::MetadataPartitionLookup`]
160    /// (#962) and `WHERE pk IN (...)` fans out to [`AccessPath::MultiPartitionLookup`]
161    /// (#1916); this reason is reserved for the genuinely unclassifiable metadata
162    /// scan that remains.
163    MetadataScanPath,
164
165    /// The legacy `QueryExecutor` issues an unconditional `storage.scan`; it does
166    /// not consult the fast path. Since issue #1750 ad-hoc SELECTs route through
167    /// the modern executor, so this reason now covers the remaining legacy SELECT
168    /// surfaces (a cached legacy SELECT plan reused via the plan-cache HIT branch).
169    /// Tracked by #962 (route the legacy/prepared surfaces through the modern
170    /// executor) and #961 (param binding).
171    LegacyExecutorPath,
172
173    /// The operator forced the full-scan path via the read-path forcing knob
174    /// (`CQLITE_READ_PATH=full` or [`crate::config::ReadPathMode::Full`], issue
175    /// #1918). This is a *forced* fallback, distinct from every organic reason so
176    /// a deliberately-forced full scan is never mistaken for one the classifier
177    /// chose on its own. The rows are byte-identical to the `auto` result for the
178    /// same query — forcing governs routing only, never decoding.
179    ForcedFullScan,
180
181    /// The `tombstones` build compiles out the partition-targeted prune. On that
182    /// build the targeted storage surfaces (`scan_partition`,
183    /// `scan_partition_with_cell_metadata`) are full-scan + retain fallbacks with
184    /// NO bloom/BTI SSTable pruning, so a fully-constrained `WHERE pk = ?` (or
185    /// `IN (...)`/WRITETIME-TTL) opens the whole table even though the rows are
186    /// byte-identical to the pruned build. The executor reports this honest reason
187    /// (rather than a fake targeted label) whenever the storage call returns
188    /// `engaged == false` (Epic #951, honest access paths).
189    TombstonesBuildNoPrune,
190}
191
192impl FallbackReason {
193    /// A stable, lowercase label suitable for `EXPLAIN`-style output and JSON.
194    pub fn label(&self) -> &'static str {
195        match self {
196            FallbackReason::NoSchema => "no_schema",
197            FallbackReason::PartitionKeyNotFullyConstrained => {
198                "partition_key_not_fully_constrained"
199            }
200            FallbackReason::PartitionKeyEncodingFailed => "partition_key_encoding_failed",
201            FallbackReason::MetadataScanPath => "metadata_scan_path",
202            FallbackReason::ForcedFullScan => "forced_full_scan",
203            FallbackReason::LegacyExecutorPath => "legacy_executor_path",
204            FallbackReason::TombstonesBuildNoPrune => "tombstones_build_no_prune",
205        }
206    }
207}
208
209impl std::fmt::Display for AccessPath {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        match self {
212            AccessPath::FallbackFullScan { reason } => {
213                write!(f, "{} ({})", self.label(), reason.label())
214            }
215            _ => f.write_str(self.label()),
216        }
217    }
218}
219
220/// Process-global record of the most recent access path selected by a SELECT
221/// SSTable-scan step.
222///
223/// An [`ArcSwapOption`] rather than a thread-local because the streaming SELECT
224/// path runs inside a spawned task; the probe must be observable from a different
225/// thread than the one that called `execute_streaming`. Lock-free (issue #1595):
226/// the signal is written on every SELECT, so it must not be a serialization point.
227/// It is not gated behind `cfg(test)` because integration tests in `tests/` compile
228/// against the library crate without its `test` cfg (same rationale as
229/// `SCAN_FOR_KEY_CALLS`, issue #831).
230static LAST_ACCESS_PATH: ArcSwapOption<AccessPath> = ArcSwapOption::const_empty();
231
232/// Record the access path chosen by a SELECT SSTable-scan step.
233///
234/// Called at each decision boundary in the executor. The last call before a test
235/// reads [`last`] reflects the path the query actually took. The store is lock-free
236/// and infallible (no poisoning surface); recording is a diagnostic side channel and
237/// never aborts a query.
238///
239/// Additionally emits the degraded read-path counter
240/// (`cqlite.query.degraded_path.total`, issue #2163) exactly when `path` is an
241/// honest [`AccessPath::FallbackFullScan`], carrying the bounded
242/// [`FallbackReason::label`] as `cqlite.query.fallback_reason`. A targeted path
243/// never increments it. The emission is a no-op with zero OTel linkage when the
244/// `observability` feature is off.
245pub fn record(path: AccessPath) {
246    if let AccessPath::FallbackFullScan { reason } = &path {
247        crate::observability::add_counter(
248            crate::observability::catalog::QUERY_DEGRADED_PATH,
249            1,
250            &[(
251                crate::observability::catalog::attr::FALLBACK_REASON,
252                reason.label().into(),
253            )],
254        );
255    }
256    LAST_ACCESS_PATH.store(Some(Arc::new(path)));
257}
258
259/// Read the most recently recorded access path, or `None` if none was recorded
260/// since the last [`reset`].
261///
262/// Tests assert against this after running a query. Mirrors
263/// `SSTableReader::scan_for_key_call_count` (issue #831).
264pub fn last() -> Option<AccessPath> {
265    LAST_ACCESS_PATH.load_full().map(|path| (*path).clone())
266}
267
268/// Clear the recorded access path. Tests call this before a query so a stale
269/// value from a previous query cannot satisfy the assertion.
270pub fn reset() {
271    LAST_ACCESS_PATH.store(None);
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn full_scan_is_not_targeted() {
280        assert!(AccessPath::FullScan.is_full_scan());
281        assert!(!AccessPath::FullScan.is_targeted());
282    }
283
284    #[test]
285    fn fallback_is_full_scan_and_carries_reason() {
286        let p = AccessPath::FallbackFullScan {
287            reason: FallbackReason::NoSchema,
288        };
289        assert!(p.is_full_scan());
290        assert!(!p.is_targeted());
291        assert_eq!(p.to_string(), "fallback_full_scan (no_schema)");
292    }
293
294    #[test]
295    fn partition_lookup_is_targeted() {
296        assert!(AccessPath::PartitionLookup.is_targeted());
297        assert!(!AccessPath::PartitionLookup.is_full_scan());
298        assert_eq!(AccessPath::PartitionLookup.label(), "partition_lookup");
299    }
300
301    #[test]
302    fn serde_tag_matches_label() {
303        // The serialized form must equal the documented stable label(), not the
304        // Rust variant name. A simple variant serializes as a bare JSON string.
305        assert_eq!(
306            serde_json::to_string(&AccessPath::PartitionLookup).unwrap(),
307            "\"partition_lookup\""
308        );
309        assert_eq!(
310            serde_json::to_string(&AccessPath::MetadataPartitionLookup).unwrap(),
311            "\"metadata_partition_lookup\""
312        );
313        // FallbackReason tag also matches its label().
314        assert_eq!(
315            serde_json::to_string(&FallbackReason::MetadataScanPath).unwrap(),
316            "\"metadata_scan_path\""
317        );
318        // Round-trips.
319        let p = AccessPath::FallbackFullScan {
320            reason: FallbackReason::PartitionKeyNotFullyConstrained,
321        };
322        let json = serde_json::to_string(&p).unwrap();
323        assert_eq!(serde_json::from_str::<AccessPath>(&json).unwrap(), p);
324    }
325
326    #[test]
327    fn probe_round_trips() {
328        reset();
329        assert_eq!(last(), None);
330        record(AccessPath::PartitionLookup);
331        assert_eq!(last(), Some(AccessPath::PartitionLookup));
332        reset();
333        assert_eq!(last(), None);
334    }
335}