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}