cqlite_core/query/select_executor/execute.rs
1//! Async pipeline entry points for the SELECT executor (issue #1174).
2//!
3//! This submodule continues the [`SelectExecutor`](super::SelectExecutor) `impl`
4//! with the three large `async` pipeline methods that drive query execution:
5//! - [`SelectExecutor::execute`] — the materializing plan runner,
6//! - [`SelectExecutor::execute_streaming_background`] — the streaming producer
7//! task spawned by `execute_streaming` (which stays in `mod.rs`),
8//! - [`SelectExecutor::execute_sstable_scan`] — the SSTable-scan step.
9//!
10//! These were relocated verbatim from `mod.rs` (epic #1116 file-size split); the
11//! per-step helpers they call (`execute_filter`, `execute_sort`, etc.) and the
12//! `ExecutionContext` bookkeeping struct remain in `mod.rs`. As a child module,
13//! this file can reach `mod.rs`'s private items directly — the logic, ordering,
14//! and error handling are unchanged.
15
16use super::{
17 build_row_from_scan, classify_partition_lookup, column_info_from_type_str, evaluate_predicates,
18 honest_targeted_path, parse_table_id, partition_key_digest, project_expr_reshapes_row,
19 select_has_writetime_ttl, sort_rows_by_token, validate_token_predicates,
20 PartitionLookupOutcome, SSTablePredicate,
21};
22use super::{
23 AccessPath, ColumnInfo, ExecutionContext, ExecutionStep, FallbackReason, OptimizedQueryPlan,
24 ProjectionFlags, QueryResult, QueryRow, Result, SelectExecutor, StorageEngine, TableId,
25 TableSchema,
26};
27use crate::query::result_budget::enforce_materialized_rows;
28use std::sync::Arc;
29use tokio::sync::mpsc;
30
31impl SelectExecutor {
32 /// Execute an optimized query plan.
33 ///
34 /// Instrumented as `query.select.plan` (issue #1035): this span covers the
35 /// modern SELECT pipeline — SSTable scan, filtering, projection, aggregation,
36 /// and WRITETIME/TTL metadata extraction — and is the parent under which the
37 /// read-path spans (issue #1034) nest. On completion it emits
38 /// [`catalog::QUERY_ROWS_SCANNED`] (rows the scan step examined) dimensioned by
39 /// the honest access path, so the rows-scanned vs rows-returned gap is
40 /// observable. The bounded access-path attribute is recorded on the span; the
41 /// query text and key values never are.
42 #[tracing::instrument(
43 name = "query.select.plan",
44 skip_all,
45 fields(
46 cqlite.query.access_path = tracing::field::Empty,
47 cqlite.query.rows_scanned = tracing::field::Empty,
48 cqlite.query.rows = tracing::field::Empty,
49 )
50 )]
51 pub async fn execute(&self, plan: OptimizedQueryPlan) -> Result<QueryResult> {
52 // Issue #960: clear the global access-path probe so a stale value from a
53 // previous query cannot satisfy a test assertion against this one.
54 crate::query::access_path::reset();
55
56 let table_id = if let Some(ref from_clause) = plan.statement.from_clause {
57 self.extract_table_id(from_clause)?
58 } else {
59 // For queries without FROM clause (like SELECT 1), use a dummy table ID
60 TableId::new("_dummy_")
61 };
62
63 // Issue #1587 (E5): resolve the table's schema ONCE per query into a shared
64 // `Arc<TableSchema>`. Column-metadata building, the SSTable scan, and the
65 // SELECT-* metadata fallback all borrow this same schema (ref-count bump),
66 // instead of each independently re-locking the registry and deep-cloning a
67 // fresh `TableSchema` (2–4 deep clones per query before this).
68 let query_schema: Option<Arc<TableSchema>> = if plan.statement.from_clause.is_some() {
69 self.resolve_table_schema(&table_id).await
70 } else {
71 None
72 };
73
74 // Issue #692: detect whether any WRITETIME/TTL select items are present
75 // during planning and set the opt-in flag so the reader threads per-cell
76 // metadata. This is the "planning" half of the executor wiring; the
77 // "evaluation" half lives in `evaluate_select_expression`.
78 let projection_flags = ProjectionFlags {
79 include_cell_metadata: select_has_writetime_ttl(&plan.statement),
80 };
81 log::debug!(
82 "Query plan: include_cell_metadata={}",
83 projection_flags.include_cell_metadata
84 );
85
86 let mut context = ExecutionContext {
87 table_id,
88 columns: self.get_result_columns(&plan.statement, query_schema.as_deref())?,
89 rows_processed: 0,
90 scan_rows: 0,
91 projection_flags,
92 access_path: None,
93 reverse_served: false,
94 };
95
96 // Handle queries without FROM clause (like SELECT 1)
97 if plan.statement.from_clause.is_none() {
98 let mut result = self.execute_constant_query(&plan.statement, &context)?;
99 // Issue #1582 (roborev): apply the statement's LIMIT/OFFSET to the
100 // constant rows BEFORE the byte + row-count budget check, so the budget
101 // is enforced on the rows ACTUALLY returned (post LIMIT/OFFSET) —
102 // consistent with the table-backed path below. In particular `LIMIT 0`
103 // must return empty, never `ResultTooLarge`; an over-budget constant
104 // SELECT with NO limit still trips the guard on its final rows.
105 let offset = plan.statement.offset.unwrap_or(0) as usize;
106 let limit = plan
107 .statement
108 .limit
109 .as_ref()
110 .map(|l| l.count as usize)
111 .unwrap_or(usize::MAX);
112 result.rows = result.rows.into_iter().skip(offset).take(limit).collect();
113 // Keep the row-count metadata consistent with the returned rows.
114 let returned = result.rows.len() as u64;
115 result.rows_affected = returned;
116 result.metadata.total_rows = Some(returned);
117 enforce_materialized_rows(&result.rows, self.max_result_bytes, self.max_result_rows)?;
118 return Ok(result);
119 }
120
121 // Execute the plan step by step
122 let mut intermediate_results = Vec::new();
123
124 // If no execution steps are provided, add a default table scan
125 let execution_steps = if plan.execution_steps.is_empty() {
126 vec![ExecutionStep::SSTableScan {
127 table: context.table_id.clone(),
128 predicates: vec![],
129 projection: context.columns.iter().map(|c| c.name.clone()).collect(),
130 }]
131 } else {
132 plan.execution_steps.clone()
133 };
134
135 for step in &execution_steps {
136 match step {
137 ExecutionStep::SSTableScan {
138 table,
139 predicates,
140 projection,
141 ..
142 } => {
143 let rows = self
144 .execute_sstable_scan(
145 table,
146 predicates,
147 projection,
148 plan.statement.order_by.as_ref(),
149 query_schema.as_deref(),
150 &mut context,
151 )
152 .await?;
153 intermediate_results = rows;
154 }
155 ExecutionStep::Filter { expression, .. } => {
156 intermediate_results =
157 self.execute_filter(intermediate_results, expression, &mut context)?;
158 }
159 ExecutionStep::Sort { order_by, .. } => {
160 // Issue #1184: when the BIG reverse partition iterator already
161 // produced the rows in descending clustering order, skip the
162 // in-memory sort entirely (it remains the fallback otherwise).
163 if !context.reverse_served {
164 intermediate_results =
165 self.execute_sort(intermediate_results, order_by, &mut context)?;
166 } else {
167 // Issue #1307 (hardening): skipping this Sort is sound ONLY
168 // because `reverse_served` is set exclusively by
169 // `targeted_partition_rows`, which serves the reverse
170 // promoted-index iterator precisely when `statement.order_by`
171 // requests the reverse of the stored clustering order — and
172 // the planner emits EXACTLY ONE `Sort` step, cloned from that
173 // same `statement.order_by` (see `select_optimizer.rs`). So
174 // the reverse scan's ordering matches this step's `order_by`,
175 // and skipping it drops a redundant sort rather than a
176 // different ordering. What would break the invariant: a
177 // multi-table / join plan (or any plan) that reused the flag
178 // across a Sort NOT derived from the reverse-served scan's
179 // `statement.order_by` — such a plan must clear
180 // `reverse_served` before this step. The debug_assert pins
181 // the property (the skipped Sort's key equals the statement's
182 // order_by); it is debug-only and never alters release
183 // behavior.
184 debug_assert!(
185 plan.statement.order_by.as_ref() == Some(order_by),
186 "reverse_served Sort-skip invariant violated: the skipped \
187 Sort's order_by must be the statement's order_by that drove \
188 the reverse-served scan (single-table plan); a plan whose \
189 Sort is not the reverse scan's matching Sort must clear \
190 reverse_served first",
191 );
192 }
193 }
194 ExecutionStep::Aggregate { plan: agg_plan, .. } => {
195 intermediate_results =
196 self.execute_aggregation(intermediate_results, agg_plan, &mut context)?;
197 }
198 ExecutionStep::PerPartitionLimit { count } => {
199 intermediate_results =
200 Self::execute_per_partition_limit(intermediate_results, *count);
201 }
202 ExecutionStep::Limit { count, offset } => {
203 intermediate_results =
204 self.execute_limit(intermediate_results, *count, *offset, &mut context)?;
205 }
206 ExecutionStep::Project { columns } => {
207 // Issue #1952 (round-6 fix): branch on whether the projection
208 // RESHAPES the row. A plain-column Project only trims the
209 // #1952-widened helper columns — route it through the
210 // key-preserving `trim_projection` so the row keeps its real
211 // RowKey / metadata and a sparse row's absent selected cell is
212 // omitted rather than erroring. Only a reshaping / computed
213 // projection (alias, arithmetic, aggregate, function,
214 // writetime/ttl, collection-access) goes through
215 // `execute_projection`, whose empty-RowKey + name-derivation is
216 // correct for a computed row that has no natural stored key.
217 intermediate_results = if columns.iter().any(project_expr_reshapes_row) {
218 self.execute_projection(intermediate_results, columns, &mut context)?
219 } else {
220 self.trim_projection(intermediate_results, columns)
221 };
222 }
223 }
224 }
225
226 // Issue #1582 (D6, narrow subset): enforce the byte-bounded result budget
227 // (primary) + the row-count safety valve (secondary) with a SINGLE robust
228 // check on the FINAL materialized result — AFTER every execution step
229 // (Limit/Offset/Filter/Sort/Aggregate/Project) has produced the rows that
230 // will actually be returned. Because this sees ONLY the returned rows
231 // (post LIMIT/OFFSET), a `LIMIT 10` query never trips and OFFSET-skipped
232 // rows are never charged. This replaces the earlier during-collection
233 // machinery (LIMIT/OFFSET pushdown, per-row early-stop, storage-layer row limit),
234 // which kept generating correctness edge cases; a single final-result
235 // check has no such edges. Reuses the shared `estimate_value_size`
236 // estimator (via `enforce_materialized_rows`).
237 //
238 // SCOPE (owner-accepted boundaries, tracked on #1582 — NOT bugs to fix in
239 // this narrow subset):
240 // * Does NOT bound PEAK scan memory: `storage.scan` still materializes
241 // each reader's matching rows before this point (deferred to #1897).
242 // * Does NOT cover the LEGACY point-lookup `QueryExecutor` path
243 // (`WHERE id = ?` short lookups route there, not through this modern
244 // executor) — deferred to the D6 redesign.
245 enforce_materialized_rows(
246 &intermediate_results,
247 self.max_result_bytes,
248 self.max_result_rows,
249 )?;
250
251 let total_rows = intermediate_results.len() as u64;
252
253 // CRITICAL FIX (Issue #129/#140): Populate metadata.columns for SELECT *
254 // When SELECT * is used and no schema was found, context.columns is empty.
255 // Fall back to inferring column names from the first row's HashMap keys.
256 // IMPORTANT: Must be sorted alphabetically for deterministic JSON output (Issue #129)!
257 let mut columns = context.columns;
258 if columns.is_empty() && !intermediate_results.is_empty() {
259 // Issue #1587 (E5): reuse the schema resolved once at the top of the
260 // query rather than re-locking the registry + deep-cloning here.
261 let schema_opt = query_schema.as_deref();
262
263 let first_row = &intermediate_results[0];
264 let mut col_names: Vec<_> = first_row.values.keys().collect();
265 col_names.sort(); // Sort alphabetically for deterministic ordering (Issue #129)
266
267 let table_name_for_meta = schema_opt.map(|s| format!("{}.{}", s.keyspace, s.table));
268
269 for (idx, col_name) in col_names.iter().enumerate() {
270 let col_name: &str = col_name;
271 // Look up CQL type from schema; derive flat DataType from it (Issue #674).
272 let col_info = match schema_opt
273 .and_then(|schema| schema.columns.iter().find(|c| c.name.as_str() == col_name))
274 {
275 Some(schema_col) => column_info_from_type_str(
276 col_name.to_string(),
277 &schema_col.data_type,
278 idx,
279 table_name_for_meta.clone(),
280 ),
281 None => ColumnInfo {
282 name: col_name.to_string(),
283 data_type: crate::types::DataType::Text,
284 nullable: true,
285 position: idx,
286 table_name: table_name_for_meta.clone(),
287 cql_type: None,
288 },
289 };
290 columns.push(col_info);
291 }
292 }
293
294 // Observability (issue #1035): the `query.select.plan` span declared
295 // `access_path`/`rows_scanned`/`rows` but never recorded them, and
296 // `QUERY_ROWS_SCANNED` was never emitted. Do both here, sourced from the
297 // honest per-query signal (`context.access_path`, set by the SSTable-scan
298 // step) and the rows the scan examined (`context.rows_processed`). Bounded
299 // attributes only — never the query text or key values.
300 {
301 use crate::observability::{self as obs, catalog, AttrValue};
302
303 let access_path_label: &'static str = context
304 .access_path
305 .as_ref()
306 .map(|p| p.label())
307 .unwrap_or("unknown");
308
309 obs::add_counter(
310 catalog::QUERY_ROWS_SCANNED,
311 context.scan_rows,
312 &[(
313 catalog::attr::ACCESS_PATH,
314 AttrValue::StaticStr(access_path_label),
315 )],
316 );
317
318 let span = tracing::Span::current();
319 span.record(catalog::attr::ACCESS_PATH, access_path_label);
320 span.record("cqlite.query.rows_scanned", context.scan_rows);
321 span.record("cqlite.query.rows", total_rows);
322 }
323
324 // Issue #1035: carry a bounded plan family on the result so the engine's
325 // single observability chokepoint reports a real plan type for SELECTs
326 // (the modern executor previously always returned `plan_info: None`,
327 // forcing plan_type to "unknown").
328 let plan_info = Self::select_plan_info(&plan, context.access_path.as_ref());
329
330 Ok(QueryResult {
331 rows: intermediate_results,
332 rows_affected: total_rows, // Use actual number of rows returned
333 execution_time_ms: 0, // Will be set by the engine
334 metadata: crate::query::result::QueryMetadata {
335 columns,
336 total_rows: Some(total_rows),
337 plan_info: Some(plan_info),
338 performance: Default::default(),
339 warnings: vec![],
340 // Issue #960: surface the access path the SSTable-scan step chose
341 // on the result from PER-QUERY state (not the global probe), so a
342 // concurrent SELECT cannot overwrite it between record() and here.
343 access_path: context.access_path.clone(),
344 },
345 })
346 }
347
348 /// Background task: Execute streaming scan and send rows through channel
349 pub(super) async fn execute_streaming_background(
350 storage: Arc<StorageEngine>,
351 // Issue #1587 (E5): schema resolved ONCE per query by `execute_streaming`
352 // and moved into this task — no per-scan-step registry lock + deep clone.
353 query_schema: Option<Arc<TableSchema>>,
354 _table_id: TableId,
355 execution_steps: Vec<ExecutionStep>,
356 tx: mpsc::Sender<Result<QueryRow>>,
357 buffer_size: usize,
358 ) -> Result<()> {
359 // Issue #581: LIMIT/OFFSET must be enforced by the producer in the
360 // streaming path. The `ExecutionStep::Limit` arm previously only logged a
361 // message and relied on a consumer that never applied it, so
362 // `execute_streaming` yielded the full result set regardless of LIMIT.
363 // Extract the bound up front (steps are ordered with Limit after the scan)
364 // and stop sending once it is satisfied — mirroring `execute_limit`
365 // (drain OFFSET, then truncate to `count`) row-by-row so the producer
366 // stops scanning early.
367 let limit = execution_steps.iter().find_map(|step| match step {
368 ExecutionStep::Limit { count, offset } => Some((*count, offset.unwrap_or(0))),
369 _ => None,
370 });
371 let (limit_count, mut offset_remaining) = match limit {
372 Some((count, offset)) => (Some(count), offset),
373 None => (None, 0),
374 };
375
376 // A `LIMIT 0` means no rows can ever be sent; return before scanning.
377 if limit_count == Some(0) {
378 return Ok(());
379 }
380
381 // Issue #757: PER PARTITION LIMIT caps rows per partition before the
382 // query-wide LIMIT/OFFSET. The scan yields rows grouped by partition
383 // key, so we track the current partition and reset the counter at each
384 // boundary. Issue #1590 (E8): the boundary is compared on the partition
385 // key's 128-bit `partition_key_digest` (a heap-free hash of the key bytes
386 // we already hold) as a FAST pre-check, then confirmed by EXACT byte
387 // equality against the current partition's raw bytes — stored ONCE when
388 // the boundary advances, never cloned per row. This keeps correctness
389 // independent of digest collisions (a collision between two DISTINCT
390 // partitions never shares a counter).
391 let per_partition_limit = execution_steps.iter().find_map(|step| match step {
392 ExecutionStep::PerPartitionLimit { count } => Some(*count),
393 _ => None,
394 });
395 let mut current_partition: Option<(u128, Vec<u8>)> = None;
396 let mut partition_count: u64 = 0;
397
398 let mut sent: u64 = 0;
399
400 for step in &execution_steps {
401 match step {
402 ExecutionStep::SSTableScan {
403 table,
404 predicates,
405 projection,
406 ..
407 } => {
408 let schema_opt = query_schema.as_deref();
409
410 // FINDING 2 (Issue #955 follow-up): reject a `token(...)` whose
411 // columns are not the full partition key in declared order
412 // before scanning (same rule as the materializing path).
413 validate_token_predicates(predicates, schema_opt)?;
414
415 // Issue #949: a fully-constrained `WHERE pk = ?` is served by a
416 // partition-targeted lookup that prunes SSTables via bloom/BTI,
417 // instead of streaming a scan over every SSTable. The resulting
418 // rows are sent through the same per-row pipeline below
419 // (predicates, PER PARTITION LIMIT, OFFSET, LIMIT). Note
420 // `scan_partition` reconciles across SSTable generations like the
421 // materializing `scan()` (last-write-wins + tombstone shadowing),
422 // which is the authoritative read semantics; it does not merely
423 // mirror `scan_stream`'s per-key merge.
424 let lookup = classify_partition_lookup(predicates, schema_opt);
425 if let PartitionLookupOutcome::Targeted(ref pk_bytes) = lookup {
426 // Issue #960: the streaming analogue of the materializing
427 // partition-targeted lookup. Epic #951 (honest paths): the
428 // `tombstones` build's `scan_partition` is a full-scan +
429 // retain with NO prune, reported via `engaged == false`; only
430 // claim `StreamingPartitionLookup` when it really pruned.
431 let (rows, engaged) =
432 storage.scan_partition(table, pk_bytes, schema_opt).await?;
433 crate::query::access_path::record(honest_targeted_path(
434 AccessPath::StreamingPartitionLookup,
435 engaged,
436 ));
437 for (key, value) in rows {
438 let part_sig =
439 per_partition_limit.map(|_| partition_key_digest(&key.0));
440 let Some(row) = build_row_from_scan(key, value, projection, schema_opt)
441 else {
442 continue;
443 };
444 if !evaluate_predicates(&row, predicates)? {
445 continue;
446 }
447 if let (Some(cap), Some(sig)) = (per_partition_limit, part_sig) {
448 // Fast digest pre-check, then EXACT byte confirm so a
449 // digest collision between DISTINCT partitions never
450 // shares a counter (issue #1590).
451 let same = matches!(
452 ¤t_partition,
453 Some((d, bytes))
454 if *d == sig && bytes.as_slice() == row.key.0.as_slice()
455 );
456 if !same {
457 // Clone the key bytes ONCE per boundary, not per row.
458 current_partition = Some((sig, row.key.0.clone()));
459 partition_count = 0;
460 }
461 if partition_count >= cap {
462 continue;
463 }
464 partition_count += 1;
465 }
466 if offset_remaining > 0 {
467 offset_remaining -= 1;
468 continue;
469 }
470 if tx.send(Ok(row)).await.is_err() {
471 return Ok(());
472 }
473 sent += 1;
474 if let Some(count) = limit_count {
475 if sent >= count {
476 return Ok(());
477 }
478 }
479 }
480 // This SSTableScan step is fully served by the lookup.
481 continue;
482 }
483
484 // Issue #955: `WHERE pk IN (...)` over the complete key is the
485 // union of N partition-targeted lookups. Gather them, sort by
486 // token to match full-scan order, then drive the same per-row
487 // pipeline (predicates, PER PARTITION LIMIT, OFFSET, LIMIT).
488 if let PartitionLookupOutcome::MultiTargeted(ref pk_keys) = lookup {
489 // Epic #951 (honest paths): each lookup reports whether it
490 // pruned. On the `tombstones` build every call full-scans
491 // (`engaged == false`); claim `MultiPartitionLookup` only when
492 // the lookups actually pruned, else report the honest fallback.
493 let mut combined = Vec::new();
494 let mut all_engaged = true;
495 for pk_bytes in pk_keys {
496 let (rows, engaged) =
497 storage.scan_partition(table, pk_bytes, schema_opt).await?;
498 all_engaged &= engaged;
499 combined.extend(rows);
500 }
501 crate::query::access_path::record(honest_targeted_path(
502 AccessPath::MultiPartitionLookup,
503 all_engaged,
504 ));
505 sort_rows_by_token(&mut combined);
506 for (key, value) in combined {
507 let part_sig =
508 per_partition_limit.map(|_| partition_key_digest(&key.0));
509 let Some(row) = build_row_from_scan(key, value, projection, schema_opt)
510 else {
511 continue;
512 };
513 if !evaluate_predicates(&row, predicates)? {
514 continue;
515 }
516 if let (Some(cap), Some(sig)) = (per_partition_limit, part_sig) {
517 // Fast digest pre-check, then EXACT byte confirm so a
518 // digest collision between DISTINCT partitions never
519 // shares a counter (issue #1590).
520 let same = matches!(
521 ¤t_partition,
522 Some((d, bytes))
523 if *d == sig && bytes.as_slice() == row.key.0.as_slice()
524 );
525 if !same {
526 // Clone the key bytes ONCE per boundary, not per row.
527 current_partition = Some((sig, row.key.0.clone()));
528 partition_count = 0;
529 }
530 if partition_count >= cap {
531 continue;
532 }
533 partition_count += 1;
534 }
535 if offset_remaining > 0 {
536 offset_remaining -= 1;
537 continue;
538 }
539 if tx.send(Ok(row)).await.is_err() {
540 return Ok(());
541 }
542 sent += 1;
543 if let Some(count) = limit_count {
544 if sent >= count {
545 return Ok(());
546 }
547 }
548 }
549 // This SSTableScan step is fully served by the lookups.
550 continue;
551 }
552
553 // Issue #960: the streaming path did not take a targeted
554 // lookup; report the honest fallback reason. `lookup` is the
555 // `Fallback` arm here (the `Targeted`/`MultiTargeted` arms
556 // returned above via `continue`).
557 if let PartitionLookupOutcome::Fallback(reason) = lookup {
558 crate::query::access_path::record(AccessPath::FallbackFullScan { reason });
559 }
560
561 // Issue #790: pull rows lazily from a bounded streaming scan
562 // instead of materializing the full result `Vec`. The reader
563 // parses one entry at a time into this channel, so live heap
564 // stays bounded by `buffer_size` rather than O(result rows).
565 let mut scan_stream = storage
566 .scan_stream(table, None, None, schema_opt, buffer_size)
567 .await?;
568
569 while let Some(item) = scan_stream.recv().await {
570 let (key, value) = item?;
571 // Capture the partition-key digest before `key` is moved
572 // into row construction (only when needed).
573 let part_sig = per_partition_limit.map(|_| partition_key_digest(&key.0));
574 let Some(row) = build_row_from_scan(key, value, projection, schema_opt)
575 else {
576 continue;
577 };
578
579 if !evaluate_predicates(&row, predicates)? {
580 continue;
581 }
582
583 // Apply PER PARTITION LIMIT: cap matching rows per
584 // partition, before OFFSET/LIMIT (Cassandra semantics).
585 if let (Some(cap), Some(sig)) = (per_partition_limit, part_sig) {
586 // Fast digest pre-check, then EXACT byte confirm so a
587 // digest collision between DISTINCT partitions never
588 // shares a counter (issue #1590).
589 let same = matches!(
590 ¤t_partition,
591 Some((d, bytes))
592 if *d == sig && bytes.as_slice() == row.key.0.as_slice()
593 );
594 if !same {
595 // Clone the key bytes ONCE per boundary, not per row.
596 current_partition = Some((sig, row.key.0.clone()));
597 partition_count = 0;
598 }
599 if partition_count >= cap {
600 continue;
601 }
602 partition_count += 1;
603 }
604
605 // Apply OFFSET: skip the first `offset_remaining` matches.
606 if offset_remaining > 0 {
607 offset_remaining -= 1;
608 continue;
609 }
610
611 // Send row through channel (with backpressure). Consumer drop ends the scan.
612 if tx.send(Ok(row)).await.is_err() {
613 return Ok(());
614 }
615 sent += 1;
616
617 // Apply LIMIT: stop scanning once `count` rows have been
618 // sent. Dropping `scan_stream` here signals the producer
619 // (via a closed channel) to stop parsing early.
620 if let Some(count) = limit_count {
621 if sent >= count {
622 return Ok(());
623 }
624 }
625 }
626 }
627 ExecutionStep::Limit { .. } | ExecutionStep::PerPartitionLimit { .. } => {
628 // Enforced inline during the scan above (see the bounds
629 // extracted before the loop).
630 }
631 // Projection and predicate filtering are pushed into SSTableScan above.
632 ExecutionStep::Project { .. } | ExecutionStep::Filter { .. } => {}
633 _ => {
634 // Data-safety (issue #1694): log the step's variant name only,
635 // never its contents (which carry query literals/values).
636 log::warn!(
637 "Streaming execution: skipping unsupported step {}",
638 step.variant_name()
639 );
640 }
641 }
642 }
643
644 Ok(())
645 }
646
647 /// Execute SSTable scan with predicate pushdown.
648 ///
649 /// Per-row work (build row, decode partition key, evaluate predicates) is
650 /// handled by the free helpers `build_row_from_scan` and
651 /// `evaluate_predicates`, which are shared with the streaming background
652 /// task to keep the two execution paths in lockstep.
653 ///
654 /// Issue #1582 (D6, narrow subset): this scan NO LONGER applies any
655 /// byte/row budget or LIMIT/OFFSET pushdown itself. It materializes the
656 /// predicate-matching rows and returns them; the single byte/row budget check
657 /// is applied ONCE by [`SelectExecutor::execute`] on the FINAL result, after
658 /// the whole step pipeline. `storage.scan` receives no query-derived row limit
659 /// (its `limit` argument stays `None`), so a `WHERE non_pk = ? LIMIT N` cannot
660 /// silently drop matching rows past the first N raw rows.
661 #[cfg_attr(feature = "tombstones", allow(unused_variables))]
662 pub(super) async fn execute_sstable_scan(
663 &self,
664 table: &TableId,
665 predicates: &[SSTablePredicate],
666 projection: &[String],
667 order_by: Option<&crate::query::select_ast::OrderByClause>,
668 // Issue #1587 (E5): schema resolved ONCE per query by the caller and
669 // shared by reference — no per-scan registry lock + deep clone.
670 schema_opt: Option<&TableSchema>,
671 context: &mut ExecutionContext,
672 ) -> Result<Vec<QueryRow>> {
673 // FINDING 2 (Issue #955 follow-up): a `token(...)` predicate is evaluated
674 // by hashing the row's raw partition key, so its argument columns MUST be
675 // the full partition key in declared order or the result is silently
676 // wrong. Reject (Cassandra-style) before scanning/evaluating.
677 validate_token_predicates(predicates, schema_opt)?;
678
679 // Data-safety (issue #1694): log the SHAPE of the scan — predicate count
680 // and the constrained column names — never the predicate literals/values.
681 log::debug!(
682 "Executing SSTableScan: table=\"{}\", predicates={} on [{}], include_cell_metadata={}",
683 table,
684 predicates.len(),
685 predicates
686 .iter()
687 .map(|p| p.column.as_str())
688 .collect::<Vec<_>>()
689 .join(", "),
690 context.projection_flags.include_cell_metadata,
691 );
692
693 let (keyspace, table_name) = parse_table_id(table);
694
695 match schema_opt {
696 Some(schema) => log::info!(
697 "Found schema for {}.{} with {} columns",
698 schema.keyspace,
699 schema.table,
700 schema.columns.len()
701 ),
702 None => log::info!(
703 "No schema found for {}.{}, proceeding without schema-aware parsing",
704 keyspace.as_deref().unwrap_or("unknown"),
705 table_name
706 ),
707 }
708
709 // Issue #693: When WRITETIME(col) or TTL(col) is in the SELECT, use the
710 // metadata-carrying scan so per-cell timestamps reach the QueryRow.
711 let mut results = Vec::new();
712 if context.projection_flags.include_cell_metadata {
713 // Issue #962: route a fully-constrained `WHERE pk = ?` WRITETIME/TTL
714 // projection through a partition-targeted metadata lookup that prunes
715 // SSTables (bloom/BTI) before decoding, instead of full-scanning every
716 // SSTable for the table. Reuses the SAME `classify_partition_lookup`
717 // decision the non-metadata path uses (the shared resolved
718 // partition-lookup representation). The per-row predicate evaluation
719 // below is unchanged, so the pk equality itself is still applied as a
720 // correctness backstop and any bloom/BTI over-inclusion is filtered out.
721 let scan_results = match classify_partition_lookup(predicates, schema_opt) {
722 PartitionLookupOutcome::Targeted(pk_bytes) => {
723 log::info!(
724 "SSTableScan(metadata): partition-key point lookup (key len={}) for \"{}\"",
725 pk_bytes.len(),
726 table
727 );
728 // Epic #951 (honest paths): the `tombstones` build's metadata
729 // lookup is a full metadata scan + retain with NO prune,
730 // reported via `engaged == false`; claim
731 // `MetadataPartitionLookup` only when it really pruned, else
732 // report the honest `TombstonesBuildNoPrune` fallback (the
733 // rows are byte-identical either way).
734 let (rows, engaged) = self
735 .storage
736 .scan_partition_with_cell_metadata(table, &pk_bytes, schema_opt)
737 .await?;
738 let path = honest_targeted_path(AccessPath::MetadataPartitionLookup, engaged);
739 context.access_path = Some(path.clone());
740 crate::query::access_path::record(path);
741 rows
742 }
743 // Issue #962: `WHERE pk IN (...)` on the metadata path is NOT yet
744 // fanned out to N targeted metadata lookups; it still full-scans.
745 // Report that honestly (MetadataScanPath) rather than faking a
746 // targeted path — the IN-metadata fan-out is a documented follow-up.
747 PartitionLookupOutcome::MultiTargeted(_) | PartitionLookupOutcome::Fallback(_) => {
748 let metadata_path = AccessPath::FallbackFullScan {
749 reason: FallbackReason::MetadataScanPath,
750 };
751 context.access_path = Some(metadata_path.clone());
752 crate::query::access_path::record(metadata_path);
753 self.storage
754 .scan_with_cell_metadata(table, None, None, None, schema_opt)
755 .await?
756 }
757 };
758
759 log::info!("Scan (with metadata) returned {} rows", scan_results.len());
760
761 for (key, value, cell_meta) in scan_results {
762 context.rows_processed += 1;
763 context.scan_rows += 1;
764
765 let Some(mut row) = build_row_from_scan(key, value, projection, schema_opt) else {
766 continue;
767 };
768
769 // Attach per-cell metadata so evaluate_writetime_ttl can read it.
770 if !cell_meta.is_empty() {
771 row.set_cell_metadata(cell_meta);
772 }
773
774 if evaluate_predicates(&row, predicates)? {
775 results.push(row);
776 }
777 }
778 } else {
779 // Issue #949: a fully-constrained `WHERE pk = ?` is served by a
780 // partition-targeted lookup that prunes SSTables via bloom/BTI and only
781 // parses the candidates, instead of scanning every SSTable for the
782 // table. Falls back to a full scan when the partition key isn't fully
783 // pinned or can't be encoded. The per-row predicate evaluation below is
784 // unchanged, so clustering predicates and the pk equality itself are
785 // still applied (and any over-inclusion is filtered out).
786 let scan_results = match classify_partition_lookup(predicates, schema_opt) {
787 PartitionLookupOutcome::Targeted(pk_bytes) => {
788 log::info!(
789 "SSTableScan: partition-key point lookup (key len={}) for \"{}\"",
790 pk_bytes.len(),
791 table
792 );
793 // Issue #954: when a single-column clustering restriction is
794 // present, push it down to a within-partition seek so a wide
795 // partition's slice decodes O(matched rows + index), not the
796 // whole partition. The seek reports whether the clustering
797 // narrowing actually engaged; the per-row backstop below applies
798 // the exact bound so output is byte-identical either way.
799 //
800 // Issue #960: report the HONEST access path — `ClusteringSlice`
801 // only when the seek engaged, else `PartitionLookup`. The
802 // clustering seek exists only on the default build; the
803 // `tombstones` build uses the plain partition lookup.
804 // Issue #954/#960/#1184: forward clustering-slice seek OR (for
805 // `ORDER BY <ck>` reverse-of-stored) the BIG reverse iterator,
806 // with the honest access path recorded inside the helper.
807 #[cfg(not(feature = "tombstones"))]
808 {
809 self.targeted_partition_rows(
810 table, &pk_bytes, predicates, order_by, schema_opt, context,
811 )
812 .await?
813 }
814 #[cfg(feature = "tombstones")]
815 {
816 // Epic #951 (honest paths): the `tombstones` build's
817 // `scan_partition` is a full scan + retain with NO prune,
818 // reported via `engaged == false`. Report the honest
819 // fallback rather than a fake `PartitionLookup`; the rows
820 // are byte-identical to the pruned build.
821 let (rows, engaged) = self
822 .storage
823 .scan_partition(table, &pk_bytes, schema_opt)
824 .await?;
825 let path = honest_targeted_path(AccessPath::PartitionLookup, engaged);
826 context.access_path = Some(path.clone());
827 crate::query::access_path::record(path);
828 rows
829 }
830 }
831 PartitionLookupOutcome::MultiTargeted(pk_keys) => {
832 log::info!(
833 "SSTableScan: multi-partition lookup ({} keys) for \"{}\"",
834 pk_keys.len(),
835 table
836 );
837 // Issue #955/#960: `WHERE pk IN (...)` over the complete key
838 // is the union of N independent partition-targeted lookups,
839 // each of which prunes SSTables. Epic #951 (honest paths): on
840 // the `tombstones` build each lookup full-scans + retains with
841 // NO prune (`engaged == false`); report `MultiPartitionLookup`
842 // only when the lookups actually pruned, else the honest
843 // `TombstonesBuildNoPrune` fallback. Rows are unchanged.
844 let mut combined = Vec::new();
845 let mut all_engaged = true;
846 for pk_bytes in &pk_keys {
847 let (rows, engaged) = self
848 .storage
849 .scan_partition(table, pk_bytes, schema_opt)
850 .await?;
851 all_engaged &= engaged;
852 combined.extend(rows);
853 }
854 let path = honest_targeted_path(AccessPath::MultiPartitionLookup, all_engaged);
855 context.access_path = Some(path.clone());
856 crate::query::access_path::record(path);
857 // Order the union to equal a full scan filtered to these keys:
858 // partitions are stored token-ordered, so sort the combined
859 // rows by (partition token, raw key bytes). A *stable* sort
860 // keeps each partition's clustering order (rows for one key
861 // arrive contiguously from one `scan_partition`) intact.
862 sort_rows_by_token(&mut combined);
863 combined
864 }
865 PartitionLookupOutcome::Fallback(reason) => {
866 // Issue #960: report the honest reason a full scan was chosen.
867 context.access_path = Some(AccessPath::FallbackFullScan { reason });
868 crate::query::access_path::record(AccessPath::FallbackFullScan { reason });
869 // Issue #1582 (D6, narrow subset): no query-derived row limit
870 // is pushed into the predicate-unaware `storage.scan` — the sole
871 // budget check is applied once on the FINAL result in `execute`.
872 self.storage
873 .scan(table, None, None, None, schema_opt)
874 .await?
875 }
876 };
877
878 log::info!("Scan returned {} rows", scan_results.len());
879
880 for (key, value) in scan_results {
881 context.rows_processed += 1;
882 context.scan_rows += 1;
883
884 // build_row_from_scan returns None for tombstoned/null rows (Issue #191).
885 let Some(row) = build_row_from_scan(key, value, projection, schema_opt) else {
886 continue;
887 };
888
889 if evaluate_predicates(&row, predicates)? {
890 results.push(row);
891 }
892 }
893 }
894
895 Ok(results)
896 }
897}