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}