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}