Skip to main content

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                                    &current_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                                    &current_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                                &current_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}