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). The reported path must be **honest** — if the
36//! WRITETIME/TTL metadata path still full-scans today, it reports
37//! [`AccessPath::FullScan`], and a test pins that current reality so #962 can
38//! later flip it.
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. Reserved for #962 — the
77    /// metadata path full-scans today (see [`FallbackReason::MetadataScanPath`]).
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    /// The WRITETIME/TTL metadata-projection path always full-scans today; it
158    /// does not yet route through a partition-targeted lookup. Pinned by tests
159    /// so #962 can flip it to [`AccessPath::MetadataPartitionLookup`].
160    MetadataScanPath,
161
162    /// The legacy `QueryExecutor` issues an unconditional `storage.scan`; it does
163    /// not consult the fast path. Since issue #1750 ad-hoc SELECTs route through
164    /// the modern executor, so this reason now covers the remaining legacy SELECT
165    /// surfaces (a cached legacy SELECT plan reused via the plan-cache HIT branch).
166    /// Tracked by #962 (route the legacy/prepared surfaces through the modern
167    /// executor) and #961 (param binding).
168    LegacyExecutorPath,
169
170    /// The `tombstones` build compiles out the partition-targeted prune. On that
171    /// build the targeted storage surfaces (`scan_partition`,
172    /// `scan_partition_with_cell_metadata`) are full-scan + retain fallbacks with
173    /// NO bloom/BTI SSTable pruning, so a fully-constrained `WHERE pk = ?` (or
174    /// `IN (...)`/WRITETIME-TTL) opens the whole table even though the rows are
175    /// byte-identical to the pruned build. The executor reports this honest reason
176    /// (rather than a fake targeted label) whenever the storage call returns
177    /// `engaged == false` (Epic #951, honest access paths).
178    TombstonesBuildNoPrune,
179}
180
181impl FallbackReason {
182    /// A stable, lowercase label suitable for `EXPLAIN`-style output and JSON.
183    pub fn label(&self) -> &'static str {
184        match self {
185            FallbackReason::NoSchema => "no_schema",
186            FallbackReason::PartitionKeyNotFullyConstrained => {
187                "partition_key_not_fully_constrained"
188            }
189            FallbackReason::PartitionKeyEncodingFailed => "partition_key_encoding_failed",
190            FallbackReason::MetadataScanPath => "metadata_scan_path",
191            FallbackReason::LegacyExecutorPath => "legacy_executor_path",
192            FallbackReason::TombstonesBuildNoPrune => "tombstones_build_no_prune",
193        }
194    }
195}
196
197impl std::fmt::Display for AccessPath {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            AccessPath::FallbackFullScan { reason } => {
201                write!(f, "{} ({})", self.label(), reason.label())
202            }
203            _ => f.write_str(self.label()),
204        }
205    }
206}
207
208/// Process-global record of the most recent access path selected by a SELECT
209/// SSTable-scan step.
210///
211/// An [`ArcSwapOption`] rather than a thread-local because the streaming SELECT
212/// path runs inside a spawned task; the probe must be observable from a different
213/// thread than the one that called `execute_streaming`. Lock-free (issue #1595):
214/// the signal is written on every SELECT, so it must not be a serialization point.
215/// It is not gated behind `cfg(test)` because integration tests in `tests/` compile
216/// against the library crate without its `test` cfg (same rationale as
217/// `SCAN_FOR_KEY_CALLS`, issue #831).
218static LAST_ACCESS_PATH: ArcSwapOption<AccessPath> = ArcSwapOption::const_empty();
219
220/// Record the access path chosen by a SELECT SSTable-scan step.
221///
222/// Called at each decision boundary in the executor. The last call before a test
223/// reads [`last`] reflects the path the query actually took. The store is lock-free
224/// and infallible (no poisoning surface); recording is a diagnostic side channel and
225/// never aborts a query.
226///
227/// Additionally emits the degraded read-path counter
228/// (`cqlite.query.degraded_path.total`, issue #2163) exactly when `path` is an
229/// honest [`AccessPath::FallbackFullScan`], carrying the bounded
230/// [`FallbackReason::label`] as `cqlite.query.fallback_reason`. A targeted path
231/// never increments it. The emission is a no-op with zero OTel linkage when the
232/// `observability` feature is off.
233pub fn record(path: AccessPath) {
234    if let AccessPath::FallbackFullScan { reason } = &path {
235        crate::observability::add_counter(
236            crate::observability::catalog::QUERY_DEGRADED_PATH,
237            1,
238            &[(
239                crate::observability::catalog::attr::FALLBACK_REASON,
240                reason.label().into(),
241            )],
242        );
243    }
244    LAST_ACCESS_PATH.store(Some(Arc::new(path)));
245}
246
247/// Read the most recently recorded access path, or `None` if none was recorded
248/// since the last [`reset`].
249///
250/// Tests assert against this after running a query. Mirrors
251/// `SSTableReader::scan_for_key_call_count` (issue #831).
252pub fn last() -> Option<AccessPath> {
253    LAST_ACCESS_PATH.load_full().map(|path| (*path).clone())
254}
255
256/// Clear the recorded access path. Tests call this before a query so a stale
257/// value from a previous query cannot satisfy the assertion.
258pub fn reset() {
259    LAST_ACCESS_PATH.store(None);
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn full_scan_is_not_targeted() {
268        assert!(AccessPath::FullScan.is_full_scan());
269        assert!(!AccessPath::FullScan.is_targeted());
270    }
271
272    #[test]
273    fn fallback_is_full_scan_and_carries_reason() {
274        let p = AccessPath::FallbackFullScan {
275            reason: FallbackReason::NoSchema,
276        };
277        assert!(p.is_full_scan());
278        assert!(!p.is_targeted());
279        assert_eq!(p.to_string(), "fallback_full_scan (no_schema)");
280    }
281
282    #[test]
283    fn partition_lookup_is_targeted() {
284        assert!(AccessPath::PartitionLookup.is_targeted());
285        assert!(!AccessPath::PartitionLookup.is_full_scan());
286        assert_eq!(AccessPath::PartitionLookup.label(), "partition_lookup");
287    }
288
289    #[test]
290    fn serde_tag_matches_label() {
291        // The serialized form must equal the documented stable label(), not the
292        // Rust variant name. A simple variant serializes as a bare JSON string.
293        assert_eq!(
294            serde_json::to_string(&AccessPath::PartitionLookup).unwrap(),
295            "\"partition_lookup\""
296        );
297        assert_eq!(
298            serde_json::to_string(&AccessPath::MetadataPartitionLookup).unwrap(),
299            "\"metadata_partition_lookup\""
300        );
301        // FallbackReason tag also matches its label().
302        assert_eq!(
303            serde_json::to_string(&FallbackReason::MetadataScanPath).unwrap(),
304            "\"metadata_scan_path\""
305        );
306        // Round-trips.
307        let p = AccessPath::FallbackFullScan {
308            reason: FallbackReason::PartitionKeyNotFullyConstrained,
309        };
310        let json = serde_json::to_string(&p).unwrap();
311        assert_eq!(serde_json::from_str::<AccessPath>(&json).unwrap(), p);
312    }
313
314    #[test]
315    fn probe_round_trips() {
316        reset();
317        assert_eq!(last(), None);
318        record(AccessPath::PartitionLookup);
319        assert_eq!(last(), Some(AccessPath::PartitionLookup));
320        reset();
321        assert_eq!(last(), None);
322    }
323}