Skip to main content

cqlite_core/query/
executor.rs

1//! Query executor for CQLite
2//!
3//! This module provides query execution capabilities for CQL queries.
4//! It includes:
5//!
6//! - Query plan execution
7//! - Parallel query processing
8//! - Result set construction
9//! - Index utilization
10
11// CQL (Cassandra Query Language) Reference:
12// https://cassandra.apache.org/doc/latest/cassandra/developing/cql/cql_singlefile.html
13//
14// This implements CQL v3.4.3+ for Apache Cassandra 5.0+
15// CQL is NOT SQL - it's a query language specifically designed for Cassandra's distributed architecture.
16
17use super::{
18    planner::{ExecutionStep, IndexSelection, QueryPlan, StepType},
19    ComparisonOperator, Condition,
20};
21use crate::{
22    schema::SchemaManager, storage::StorageEngine, Config, Error, Result, RowKey, ScanRow, TableId,
23    Value,
24};
25use std::cmp::Ordering;
26use std::collections::HashMap;
27use std::sync::Arc;
28use std::time::Instant;
29
30// Use QueryResult and QueryRow from result module
31pub use super::result::{QueryResult, QueryRow};
32
33/// Bounded buffer (rows) for the streaming table-scan drain (issue #1691).
34///
35/// The producer parks once this many rows are in flight, so live heap during a
36/// `TableScan` stays bounded by `buffer_size` regardless of result size. This
37/// replaces the retired `execute_parallel_table_scan`, whose unbounded crossbeam
38/// channel buffered the entire result set at once and whose N (default 4) workers
39/// each issued the *identical* full `storage.scan` (4 duplicate whole-table passes
40/// for one plan).
41const TABLE_SCAN_STREAM_BUFFER: usize = 1024;
42
43/// Query executor
44#[derive(Debug, Clone)]
45pub struct QueryExecutor {
46    /// Storage engine reference
47    storage: Arc<StorageEngine>,
48    /// Schema manager reference (unused currently but kept for future use)
49    _schema: Arc<SchemaManager>,
50    /// Configuration (kept for future use; surfaced to in-file tests)
51    _config: Config,
52}
53
54impl QueryExecutor {
55    /// Create a new query executor
56    pub fn new(storage: Arc<StorageEngine>, schema: Arc<SchemaManager>, config: &Config) -> Self {
57        Self {
58            storage,
59            _schema: schema,
60            _config: config.clone(),
61        }
62    }
63
64    /// Execute a query plan.
65    ///
66    /// Instrumented as `query.execute` (issue #1035) so that surfaces which reach
67    /// the legacy executor directly — notably prepared/parameterized statements
68    /// that bypass `QueryEngine::execute` — still root a query span tree under
69    /// which the per-branch sub-spans (`query.point_lookup`, `query.table_scan`,
70    /// …) and the read-path spans (issue #1034) nest. When invoked via
71    /// `QueryEngine::execute` this nests under that span. The bounded plan-type
72    /// attribute is recorded; the query text and key values never are.
73    #[tracing::instrument(
74        name = "query.execute",
75        skip_all,
76        fields(cqlite.query.plan_type = tracing::field::Empty),
77    )]
78    pub async fn execute(&self, plan: &QueryPlan) -> Result<QueryResult> {
79        let start_time = Instant::now();
80        tracing::Span::current().record(
81            crate::observability::catalog::attr::PLAN_TYPE,
82            Self::plan_type_label(&plan.plan_type),
83        );
84
85        // Classify the plan once so subsequent dispatch is a single match.
86        let has_insert_step = plan
87            .steps
88            .iter()
89            .any(|step| matches!(step.step_type, StepType::Insert));
90        let is_create_table =
91            plan.steps.is_empty() && plan.table.is_some() && plan.estimated_rows == 0;
92
93        let result = match plan.plan_type {
94            super::planner::PlanType::PointLookup => self.execute_point_lookup(plan).await,
95            super::planner::PlanType::IndexScan => self.execute_index_scan(plan).await,
96            super::planner::PlanType::RangeScan => self.execute_range_scan(plan).await,
97            super::planner::PlanType::TableScan if has_insert_step => {
98                #[cfg(feature = "experimental")]
99                {
100                    self.execute_insert_operation(plan).await
101                }
102                #[cfg(not(feature = "experimental"))]
103                {
104                    Err(Error::UnsupportedFormat(
105                        "INSERT operations require the 'experimental' feature. \
106                         Add 'experimental' to your Cargo.toml features."
107                            .to_string(),
108                    ))
109                }
110            }
111            super::planner::PlanType::TableScan if is_create_table => {
112                self.execute_create_table_operation(plan).await
113            }
114            super::planner::PlanType::TableScan => self.execute_table_scan(plan).await,
115            super::planner::PlanType::Join => self.execute_join(plan).await,
116            super::planner::PlanType::Aggregation => self.execute_aggregation(plan).await,
117            super::planner::PlanType::Subquery => self.execute_subquery(plan).await,
118        };
119
120        let mut query_result = result?;
121        let elapsed_ms = start_time.elapsed().as_millis() as u64;
122
123        query_result.execution_time_ms = elapsed_ms;
124        query_result.metadata.plan_info = Some(super::result::PlanInfo {
125            plan_type: format!("{:?}", plan.plan_type),
126            estimated_cost: plan.estimated_cost,
127            actual_cost: elapsed_ms as f64,
128            // Access path(s) the executor actually consulted (issue #760).
129            indexes_used: Self::indexes_used_for(plan),
130            steps: plan
131                .steps
132                .iter()
133                .map(|s| format!("{:?}", s.step_type))
134                .collect(),
135            parallelization: Self::parallelization_for(plan),
136        });
137        Ok(query_result)
138    }
139
140    // -- helpers ------------------------------------------------------------
141
142    /// Bounded, lower-snake label for a [`super::planner::PlanType`], used as a
143    /// span attribute (issue #1035). The value space is the closed `PlanType`
144    /// taxonomy, so it is safe as a telemetry dimension.
145    fn plan_type_label(plan_type: &super::planner::PlanType) -> &'static str {
146        use super::planner::PlanType;
147        match plan_type {
148            PlanType::TableScan => "table_scan",
149            PlanType::IndexScan => "index_scan",
150            PlanType::PointLookup => "point_lookup",
151            PlanType::RangeScan => "range_scan",
152            PlanType::Join => "join",
153            PlanType::Aggregation => "aggregation",
154            PlanType::Subquery => "subquery",
155        }
156    }
157
158    /// Report the access path(s) the executor *actually consulted* for `plan`,
159    /// for the `indexes_used` field of [`super::result::PlanInfo`] (issue #760,
160    /// Epic #756).
161    ///
162    /// # Truthfulness contract (no-heuristics spirit, issue #28)
163    ///
164    /// This mirrors the dispatch in [`Self::execute`] and reports only what the
165    /// executed code path genuinely does — never what the planner merely
166    /// preferred. The storage layer (`StorageEngine::get` / `scan`) does not yet
167    /// surface *which* on-disk structure (Index.db partition lookup, Summary.db
168    /// sampling, or BTI trie) resolved a partition, so we cannot distinguish
169    /// those sub-paths. We therefore report at the granularity we can prove:
170    ///
171    /// - **Point lookup** (`PointLookup`, and `IndexScan` over a `Primary` or
172    ///   `BloomFilter` index) calls `StorageEngine::get`, which resolves the
173    ///   partition through the partition index. We report the selected index's
174    ///   name (e.g. `"PRIMARY"`).
175    /// - **Sequential scan** (`TableScan`, `RangeScan`, and `IndexScan` over a
176    ///   `Secondary`/`Composite` index — these currently degrade to a full scan
177    ///   in the executor) reports the explicit marker `"scan"`.
178    ///
179    /// ## Scan-marker decision
180    ///
181    /// The issue allows either an empty list or an explicit marker for a full
182    /// scan; we pick the explicit **`"scan"`** marker. An empty list is
183    /// ambiguous (it cannot be told apart from "not yet recorded"), whereas an
184    /// explicit marker makes EXPLAIN-style output and bindings stats truthful
185    /// and self-describing.
186    /// Report the parallelization metadata for the *actually executed* path, for
187    /// the `parallelization` field of [`super::result::PlanInfo`].
188    ///
189    /// # Truthfulness contract (no-heuristics spirit, issue #28; issue #1691)
190    ///
191    /// A step's `parallelization.can_parallelize` reflects only what the *planner*
192    /// suggested, not what the executor did. Since issue #1691 the `TableScan`
193    /// path is served by a SINGLE bounded `scan_stream` pass
194    /// (`streaming_scan_rows`) rather than N parallel workers, so for that path we
195    /// report the truth: one thread, `effective: false`. Reporting the planner's
196    /// suggested thread count with `effective: true` here would be inaccurate.
197    ///
198    /// For any other plan type we still surface the planner's suggested thread
199    /// count with `effective: true` when a step opts into parallelization.
200    fn parallelization_for(plan: &QueryPlan) -> Option<super::result::ParallelizationInfo> {
201        use super::planner::PlanType;
202
203        let step = plan
204            .steps
205            .iter()
206            .find(|s| s.parallelization.can_parallelize)?;
207
208        // The table-scan path no longer forks parallel workers; it executes a
209        // single bounded streaming pass. Report that truthfully.
210        if matches!(plan.plan_type, PlanType::TableScan) {
211            return Some(super::result::ParallelizationInfo {
212                threads_used: 1,
213                effective: false,
214                partitions: Vec::new(),
215            });
216        }
217
218        Some(super::result::ParallelizationInfo {
219            threads_used: step.parallelization.suggested_threads,
220            effective: true,
221            partitions: Vec::new(),
222        })
223    }
224
225    fn indexes_used_for(plan: &QueryPlan) -> Vec<String> {
226        use super::planner::{IndexType, PlanType, StepType};
227
228        // The marker used for any path that walks rows sequentially.
229        let scan = || vec!["scan".to_string()];
230
231        // A TableScan plan can actually be an INSERT or a CREATE TABLE; those
232        // are dispatched away from `execute_table_scan` in `execute()` and never
233        // call `storage.scan`, so they have no access path to report (roborev
234        // job 40). Mirror that classification here.
235        let has_insert_step = plan
236            .steps
237            .iter()
238            .any(|step| matches!(step.step_type, StepType::Insert));
239        let is_create_table =
240            plan.steps.is_empty() && plan.table.is_some() && plan.estimated_rows == 0;
241        if matches!(plan.plan_type, PlanType::TableScan) && (has_insert_step || is_create_table) {
242            return Vec::new();
243        }
244
245        match plan.plan_type {
246            // Resolves a single partition via `StorageEngine::get`.
247            PlanType::PointLookup => match plan.selected_indexes.first() {
248                Some(idx) => vec![idx.index_name.clone()],
249                // No selected index recorded but we still did a partition
250                // lookup — report the generic primary-key path.
251                None => vec!["PRIMARY".to_string()],
252            },
253            // IndexScan dispatch depends on the index type: Primary/Bloom do a
254            // real point lookup; Secondary/Composite degrade to a full scan.
255            PlanType::IndexScan => match plan.selected_indexes.first() {
256                Some(idx) => match idx.index_type {
257                    IndexType::Primary | IndexType::BloomFilter => {
258                        vec![idx.index_name.clone()]
259                    }
260                    IndexType::Secondary | IndexType::Composite => scan(),
261                },
262                None => scan(),
263            },
264            // Sequential-scan paths.
265            PlanType::TableScan | PlanType::RangeScan => scan(),
266            // Placeholder executors return empty results without touching any
267            // index structure; report nothing rather than fabricate a path.
268            PlanType::Join | PlanType::Aggregation | PlanType::Subquery => Vec::new(),
269        }
270    }
271
272    /// Resolve `plan.table` or surface a uniform query-execution error.
273    fn require_table<'a>(&self, plan: &'a QueryPlan) -> Result<&'a TableId> {
274        plan.table
275            .as_ref()
276            .ok_or_else(|| Error::query_execution("Missing table in plan"))
277    }
278
279    /// Find the first condition matching `column` across all steps.
280    fn find_condition<'a>(steps: &'a [ExecutionStep], column: &str) -> Option<&'a Condition> {
281        steps
282            .iter()
283            .flat_map(|s| s.conditions.iter())
284            .find(|c| c.column == column)
285    }
286
287    /// Convert a `(key, data)` pair from `StorageEngine::scan` into rows.
288    fn scan_pairs_to_rows(&self, pairs: Vec<(RowKey, ScanRow)>) -> Result<Vec<QueryRow>> {
289        let mut rows = Vec::with_capacity(pairs.len());
290        for (row_key, row_data) in pairs {
291            rows.push(self.storage_data_to_query_row(row_data, &row_key)?);
292        }
293        Ok(rows)
294    }
295
296    /// Run a full table scan and materialize results.
297    async fn full_scan_rows(&self, table: &TableId) -> Result<Vec<QueryRow>> {
298        let scan_results = self.storage.scan(table, None, None, None, None).await?;
299        self.scan_pairs_to_rows(scan_results)
300    }
301
302    /// Look up a single row by the key derived from `condition`.
303    async fn point_lookup_rows(
304        &self,
305        table: &TableId,
306        condition: &Condition,
307    ) -> Result<Vec<QueryRow>> {
308        let row_key = self.condition_to_row_key(condition)?;
309        match self.storage.get(table, &row_key).await? {
310            Some(row_data) => Ok(vec![self.storage_data_to_query_row(row_data, &row_key)?]),
311            None => Ok(Vec::new()),
312        }
313    }
314
315    /// Wrap a row collection in a `QueryResult`. `execution_time_ms` is set by `execute()`.
316    fn make_result(rows: Vec<QueryRow>) -> QueryResult {
317        QueryResult::with_rows(rows)
318    }
319
320    // -- plan executors -----------------------------------------------------
321
322    /// Execute point lookup plan
323    #[tracing::instrument(name = "query.point_lookup", skip_all)]
324    async fn execute_point_lookup(&self, plan: &QueryPlan) -> Result<QueryResult> {
325        let table = self.require_table(plan)?;
326
327        // Find the lookup condition (first condition of the first step that has any).
328        let lookup_condition = plan
329            .steps
330            .iter()
331            .find_map(|step| step.conditions.first())
332            .ok_or_else(|| Error::query_execution("No lookup condition found"))?;
333
334        let row_key = self.condition_to_row_key(lookup_condition)?;
335
336        let mut rows = Vec::new();
337        if let Some(row_data) = self.storage.get(table, &row_key).await? {
338            rows.push(self.storage_data_to_query_row(row_data, &row_key)?);
339        }
340
341        Ok(Self::make_result(rows))
342    }
343
344    /// Execute index scan plan
345    #[tracing::instrument(name = "query.index_scan", skip_all)]
346    async fn execute_index_scan(&self, plan: &QueryPlan) -> Result<QueryResult> {
347        let table = self.require_table(plan)?;
348
349        let index_selection = plan
350            .selected_indexes
351            .first()
352            .ok_or_else(|| Error::query_execution("No index selected"))?;
353
354        let mut rows = match index_selection.index_type {
355            super::planner::IndexType::Secondary => {
356                self.execute_secondary_index_scan(table, index_selection, &plan.steps)
357                    .await?
358            }
359            super::planner::IndexType::BloomFilter => {
360                self.execute_bloom_filter_scan(table, index_selection, &plan.steps)
361                    .await?
362            }
363            super::planner::IndexType::Primary => {
364                self.execute_primary_index_scan(table, index_selection, &plan.steps)
365                    .await?
366            }
367            super::planner::IndexType::Composite => {
368                self.execute_composite_index_scan(table, index_selection, &plan.steps)
369                    .await?
370            }
371        };
372
373        rows = self.apply_execution_steps(rows, &plan.steps).await?;
374        Ok(Self::make_result(rows))
375    }
376
377    /// Execute range scan plan
378    #[tracing::instrument(name = "query.range_scan", skip_all)]
379    async fn execute_range_scan(&self, plan: &QueryPlan) -> Result<QueryResult> {
380        let table = self.require_table(plan)?;
381
382        // Range conditions are recognized by the planner; the storage engine is
383        // queried with no explicit bounds for now.
384        let mut rows = self.full_scan_rows(table).await?;
385        rows = self.apply_execution_steps(rows, &plan.steps).await?;
386        Ok(Self::make_result(rows))
387    }
388
389    /// Execute table scan plan
390    #[tracing::instrument(name = "query.table_scan", skip_all)]
391    async fn execute_table_scan(&self, plan: &QueryPlan) -> Result<QueryResult> {
392        let table = self.require_table(plan)?;
393
394        #[cfg(debug_assertions)]
395        log::debug!("executor: Scanning for table: {:?}", table.name());
396
397        // Issue #1691: a plan that requested parallelization is served by the
398        // SAME bounded streaming scan the SelectExecutor uses (one whole-table
399        // pass, bounded mpsc, `spawn_blocking` discipline inside `scan_stream`),
400        // NOT by the retired multi-worker duplicate-scan path.
401        let can_parallelize = plan
402            .steps
403            .iter()
404            .any(|step| step.parallelization.can_parallelize);
405
406        let mut rows = if can_parallelize {
407            self.streaming_scan_rows(table).await?
408        } else {
409            self.full_scan_rows(table).await?
410        };
411
412        rows = self.apply_execution_steps(rows, &plan.steps).await?;
413        Ok(Self::make_result(rows))
414    }
415
416    /// Execute join plan (placeholder)
417    async fn execute_join(&self, _plan: &QueryPlan) -> Result<QueryResult> {
418        Ok(QueryResult::new())
419    }
420
421    /// Execute aggregation plan (placeholder)
422    async fn execute_aggregation(&self, _plan: &QueryPlan) -> Result<QueryResult> {
423        Ok(QueryResult::new())
424    }
425
426    /// Execute subquery plan (placeholder)
427    async fn execute_subquery(&self, _plan: &QueryPlan) -> Result<QueryResult> {
428        Ok(QueryResult::new())
429    }
430
431    // -- index scans --------------------------------------------------------
432
433    /// Execute secondary index scan (currently a full scan; secondary index
434    /// support is tracked separately).
435    async fn execute_secondary_index_scan(
436        &self,
437        table: &TableId,
438        index_selection: &IndexSelection,
439        steps: &[ExecutionStep],
440    ) -> Result<Vec<QueryRow>> {
441        // Validate the index condition exists; the lookup itself is not yet wired up.
442        Self::find_condition(steps, &index_selection.columns[0])
443            .ok_or_else(|| Error::query_execution("No condition found for index"))?;
444        self.full_scan_rows(table).await
445    }
446
447    /// Execute bloom filter scan (degrades to a direct point lookup).
448    async fn execute_bloom_filter_scan(
449        &self,
450        table: &TableId,
451        index_selection: &IndexSelection,
452        steps: &[ExecutionStep],
453    ) -> Result<Vec<QueryRow>> {
454        let condition = Self::find_condition(steps, &index_selection.columns[0])
455            .ok_or_else(|| Error::query_execution("No condition found for bloom filter"))?;
456        self.point_lookup_rows(table, condition).await
457    }
458
459    /// Execute primary index scan (point lookup on the primary key).
460    async fn execute_primary_index_scan(
461        &self,
462        table: &TableId,
463        index_selection: &IndexSelection,
464        steps: &[ExecutionStep],
465    ) -> Result<Vec<QueryRow>> {
466        let condition = Self::find_condition(steps, &index_selection.columns[0])
467            .ok_or_else(|| Error::query_execution("No condition found for primary key"))?;
468        self.point_lookup_rows(table, condition).await
469    }
470
471    /// Execute composite index scan (currently a full scan; composite lookups
472    /// are tracked separately).
473    async fn execute_composite_index_scan(
474        &self,
475        table: &TableId,
476        _index_selection: &IndexSelection,
477        _steps: &[ExecutionStep],
478    ) -> Result<Vec<QueryRow>> {
479        self.full_scan_rows(table).await
480    }
481
482    // -- table scans --------------------------------------------------------
483
484    /// Stream a full table scan through the bounded streaming path and
485    /// materialize results (issue #1691).
486    ///
487    /// This is the retirement of `execute_parallel_table_scan`. It issues a
488    /// SINGLE whole-table pass via [`StorageEngine::scan_stream`] — the same
489    /// bounded-`mpsc`, `spawn_blocking` machinery the `SelectExecutor` streaming
490    /// path uses (issue #790) — instead of spawning N workers that each re-ran
491    /// the identical `storage.scan`. Live heap during production stays bounded by
492    /// [`TABLE_SCAN_STREAM_BUFFER`] rows: the reader parses one entry at a time
493    /// into the channel and parks when the consumer falls behind, replacing the
494    /// old unbounded `crossbeam` channel that held the entire result set at once.
495    #[tracing::instrument(name = "query.table_scan_stream", skip_all)]
496    async fn streaming_scan_rows(&self, table: &TableId) -> Result<Vec<QueryRow>> {
497        let mut scan_stream = self
498            .storage
499            .scan_stream(table, None, None, None, TABLE_SCAN_STREAM_BUFFER)
500            .await?;
501
502        let mut rows = Vec::new();
503        while let Some(item) = scan_stream.recv().await {
504            let (row_key, row_data) = item?;
505            rows.push(self.storage_data_to_query_row(row_data, &row_key)?);
506        }
507        Ok(rows)
508    }
509
510    // -- execution-step pipeline -------------------------------------------
511
512    /// Apply execution steps to result rows.
513    ///
514    /// Limit/Aggregate/Join/Insert/Scan are no-ops at this layer (handled
515    /// elsewhere or not yet implemented); only Filter/Sort/Project transform
516    /// the row stream.
517    async fn apply_execution_steps(
518        &self,
519        mut rows: Vec<QueryRow>,
520        steps: &[ExecutionStep],
521    ) -> Result<Vec<QueryRow>> {
522        for step in steps {
523            match step.step_type {
524                StepType::Filter => rows = self.apply_filter_step(rows, step)?,
525                StepType::Sort => rows = self.apply_sort_step(rows, step),
526                StepType::Project => rows = self.apply_project_step(rows, step),
527                // Limit is enforced higher up; the rest are placeholders.
528                StepType::Limit
529                | StepType::Aggregate
530                | StepType::Join
531                | StepType::Scan
532                | StepType::Insert => {}
533            }
534        }
535        Ok(rows)
536    }
537
538    /// Apply filter step
539    fn apply_filter_step(
540        &self,
541        rows: Vec<QueryRow>,
542        step: &ExecutionStep,
543    ) -> Result<Vec<QueryRow>> {
544        let mut filtered_rows = Vec::with_capacity(rows.len());
545        for row in rows {
546            let mut matches = true;
547            for condition in &step.conditions {
548                if !self.evaluate_condition(&row, condition)? {
549                    matches = false;
550                    break;
551                }
552            }
553            if matches {
554                filtered_rows.push(row);
555            }
556        }
557        Ok(filtered_rows)
558    }
559
560    /// Apply sort step
561    fn apply_sort_step(&self, mut rows: Vec<QueryRow>, step: &ExecutionStep) -> Vec<QueryRow> {
562        let Some(sort_column) = step.columns.first() else {
563            return rows;
564        };
565
566        rows.sort_by(|a, b| {
567            let a_val = a.values.get(sort_column.as_str()).unwrap_or(&Value::Null);
568            let b_val = b.values.get(sort_column.as_str()).unwrap_or(&Value::Null);
569            self.compare_values(a_val, b_val).unwrap_or(Ordering::Equal)
570        });
571        rows
572    }
573
574    /// Apply project step
575    fn apply_project_step(&self, rows: Vec<QueryRow>, step: &ExecutionStep) -> Vec<QueryRow> {
576        rows.into_iter()
577            .map(|row| {
578                let mut projected_values = HashMap::with_capacity(step.columns.len());
579                for column in &step.columns {
580                    if let Some(value) = row.values.get(column.as_str()) {
581                        projected_values.insert(column.clone(), value.clone());
582                    }
583                }
584                QueryRow::with_values(row.key, projected_values)
585            })
586            .collect()
587    }
588
589    // -- condition / value helpers -----------------------------------------
590
591    /// Evaluate a condition against a row
592    fn evaluate_condition(&self, row: &QueryRow, condition: &Condition) -> Result<bool> {
593        let row_value = row
594            .values
595            .get(condition.column.as_str())
596            .unwrap_or(&Value::Null);
597
598        match condition.operator {
599            ComparisonOperator::Equal => Ok(row_value == &condition.value),
600            ComparisonOperator::NotEqual => Ok(row_value != &condition.value),
601            ComparisonOperator::LessThan => Ok(matches!(
602                self.compare_values(row_value, &condition.value)?,
603                Ordering::Less
604            )),
605            ComparisonOperator::LessThanOrEqual => Ok(matches!(
606                self.compare_values(row_value, &condition.value)?,
607                Ordering::Less | Ordering::Equal
608            )),
609            ComparisonOperator::GreaterThan => Ok(matches!(
610                self.compare_values(row_value, &condition.value)?,
611                Ordering::Greater
612            )),
613            ComparisonOperator::GreaterThanOrEqual => Ok(matches!(
614                self.compare_values(row_value, &condition.value)?,
615                Ordering::Greater | Ordering::Equal
616            )),
617            // Simplified IN / NOT IN: treat as equality / inequality for now.
618            ComparisonOperator::In => Ok(row_value == &condition.value),
619            ComparisonOperator::NotIn => Ok(row_value != &condition.value),
620            ComparisonOperator::Like => match (row_value, &condition.value) {
621                (Value::Text(row_text), Value::Text(pattern)) => Ok(row_text.contains(pattern)),
622                _ => Ok(false),
623            },
624            ComparisonOperator::NotLike => match (row_value, &condition.value) {
625                (Value::Text(row_text), Value::Text(pattern)) => Ok(!row_text.contains(pattern)),
626                _ => Ok(true),
627            },
628        }
629    }
630
631    /// Compare two values
632    fn compare_values(&self, a: &Value, b: &Value) -> Result<Ordering> {
633        match (a, b) {
634            (Value::Integer(a), Value::Integer(b)) => Ok(a.cmp(b)),
635            (Value::Float(a), Value::Float(b)) => Ok(a.partial_cmp(b).unwrap_or(Ordering::Equal)),
636            (Value::Text(a), Value::Text(b)) => Ok(a.cmp(b)),
637            (Value::Boolean(a), Value::Boolean(b)) => Ok(a.cmp(b)),
638            // UUID comparison: byte-wise (same as Cassandra's ordering).
639            // Covers both UUID and TIMEUUID columns — both are stored as Value::Uuid.
640            (Value::Uuid(a), Value::Uuid(b)) => Ok(a.cmp(b)),
641            (Value::Null, Value::Null) => Ok(Ordering::Equal),
642            (Value::Null, _) => Ok(Ordering::Less),
643            (_, Value::Null) => Ok(Ordering::Greater),
644            _ => Err(Error::query_execution(
645                "Cannot compare values of different types",
646            )),
647        }
648    }
649
650    /// Convert a [`Value`] to the raw partition-key bytes used by [`RowKey`] and
651    /// the Index.db lookup table.
652    ///
653    /// The encoding follows the same contract as
654    /// [`PartitionKey::to_bytes`](crate::storage::write_engine::mutation::PartitionKey::to_bytes):
655    ///
656    /// - **Single-component keys** — raw value bytes (UUID = 16 bytes, Int = 4 BE
657    ///   bytes, Text = UTF-8, BigInt = 8 BE bytes, …).
658    /// - **Multi-component (composite) keys** — `[len: u16 BE][value bytes][0x00]`
659    ///   per component, including a trailing `0x00` after the final component.
660    ///   Pass a `Value::Tuple` whose elements are the ordered PK components.
661    fn value_to_row_key(&self, value: &Value) -> Result<RowKey> {
662        match value {
663            Value::Integer(i) => Ok(RowKey::new(i.to_be_bytes().to_vec())),
664            Value::Text(s) => Ok(RowKey::new(s.as_bytes().to_vec())),
665            Value::Float(f) => Ok(RowKey::new(f.to_be_bytes().to_vec())),
666            Value::Boolean(b) => Ok(RowKey::new(vec![u8::from(*b)])),
667            Value::Null => Ok(RowKey::new(vec![0])),
668            // UUID and TIMEUUID are both stored as 16 raw bytes (no framing).
669            // This matches PartitionKey::to_bytes single-component output for a UUID column.
670            Value::Uuid(bytes) => Ok(RowKey::new(bytes.to_vec())),
671            Value::BigInt(i) => Ok(RowKey::new(i.to_be_bytes().to_vec())),
672            // Multi-component (composite) partition key passed as a Tuple.
673            // Encoding: [len: u16 BE][value bytes][0x00] per component, identical to
674            // PartitionKey::to_bytes multi-component output (see mutation.rs ~line 256).
675            Value::Tuple(components) => {
676                let mut result = Vec::new();
677                for component in components {
678                    let raw = self.value_to_raw_pk_bytes(component)?;
679                    let len = raw.len();
680                    if len > u16::MAX as usize {
681                        return Err(Error::query_execution(
682                            "Composite partition key component too large",
683                        ));
684                    }
685                    result.extend_from_slice(&(len as u16).to_be_bytes());
686                    result.extend_from_slice(&raw);
687                    result.push(0x00);
688                }
689                Ok(RowKey::new(result))
690            }
691            _ => Err(Error::query_execution("Cannot convert value to row key")),
692        }
693    }
694
695    /// Serialize a single value to raw bytes suitable for inclusion in a
696    /// composite partition key component. Used by [`value_to_row_key`] for
697    /// `Value::Tuple` components.
698    fn value_to_raw_pk_bytes(&self, value: &Value) -> Result<Vec<u8>> {
699        match value {
700            Value::Integer(i) => Ok(i.to_be_bytes().to_vec()),
701            Value::Text(s) => Ok(s.as_bytes().to_vec()),
702            Value::Float(f) => Ok(f.to_be_bytes().to_vec()),
703            Value::Boolean(b) => Ok(vec![u8::from(*b)]),
704            Value::Null => Ok(Vec::new()),
705            Value::Uuid(bytes) => Ok(bytes.to_vec()),
706            Value::BigInt(i) => Ok(i.to_be_bytes().to_vec()),
707            _ => Err(Error::query_execution(
708                "Cannot serialize value as partition key component",
709            )),
710        }
711    }
712
713    /// Convert Condition to RowKey (consistent with INSERT)
714    fn condition_to_row_key(&self, condition: &Condition) -> Result<RowKey> {
715        // Match the key format used by INSERT for "id" columns.
716        if condition.column == "id" {
717            if let Value::Integer(id) = &condition.value {
718                return Ok(RowKey::new(format!("user_key_{}", id).into_bytes()));
719            }
720        }
721        self.value_to_row_key(&condition.value)
722    }
723
724    /// Convert storage data to query row
725    fn storage_data_to_query_row(&self, data: ScanRow, key: &RowKey) -> Result<QueryRow> {
726        use std::sync::Arc;
727        let mut values: HashMap<Arc<str>, Value> = HashMap::new();
728
729        // Storage path carries rows via the `ScanRow` carrier (issue #1334).
730        // * `Row` — decoded cells keyed by the interned `Arc<str>` column-name
731        //   handle; move the handle straight in (no `String` re-allocation).
732        // * `RawRow` — a raw undecoded fallback with no schema here; surface the
733        //   bytes as a single "data" blob, the exact pre-#1334 `other =>
734        //   insert("data", ..)` shape.
735        // * `Marker` (row tombstone / null) carries no columns.
736        match data {
737            ScanRow::Row(cells) => {
738                for (name, cell_value) in cells {
739                    values.insert(name, cell_value);
740                }
741            }
742            ScanRow::RawRow(bytes) => {
743                values.insert(Arc::from("data"), Value::Blob(bytes));
744            }
745            ScanRow::Marker(_) => {}
746        }
747
748        // If no values were extracted, surface the row key for visibility.
749        if values.is_empty() {
750            values.insert(Arc::from("id"), Value::Text(format!("{:?}", key)));
751        }
752
753        Ok(QueryRow::with_interned_values(key.clone(), values))
754    }
755
756    // -- experimental write paths ------------------------------------------
757
758    /// Execute INSERT operation
759    #[cfg(feature = "experimental")]
760    async fn execute_insert_operation(&self, plan: &QueryPlan) -> Result<QueryResult> {
761        let table_id = self
762            .require_table(plan)
763            .map_err(|_| Error::query_execution("No table specified in INSERT plan"))?;
764
765        let mut inserted_count: u64 = 0;
766
767        for step in &plan.steps {
768            if !matches!(step.step_type, StepType::Insert) {
769                continue;
770            }
771
772            // Default key uses the running insert index; an explicit "id"
773            // condition wins so SELECT and INSERT share the same key shape.
774            let mut key_value = format!("test_key_{}", inserted_count);
775            for condition in &step.conditions {
776                if condition.column == "id" {
777                    if let Value::Integer(id) = &condition.value {
778                        key_value = format!("user_key_{}", id);
779                        break;
780                    }
781                }
782            }
783
784            let row_key = RowKey::new(key_value.into_bytes());
785
786            // Build the row payload from step conditions (or seed defaults
787            // when the step carries none, for test compatibility).
788            let mut value_map: HashMap<String, Value> = step
789                .conditions
790                .iter()
791                .map(|c| (c.column.clone(), c.value.clone()))
792                .collect();
793
794            if value_map.is_empty() {
795                value_map.insert("id".to_string(), Value::Integer(inserted_count as i32 + 1));
796                value_map.insert(
797                    "name".to_string(),
798                    Value::Text(format!("TestUser{}", inserted_count + 1)),
799                );
800            }
801
802            let row_value = map_to_value(value_map);
803
804            self.storage.put(table_id, row_key, row_value).await?;
805            inserted_count += 1;
806        }
807
808        // No explicit INSERT steps — emit a single placeholder row to keep
809        // legacy tests passing.
810        if inserted_count == 0 {
811            let row_key = RowKey::new(b"default_test_key".to_vec());
812            let mut value_map = HashMap::new();
813            value_map.insert("id".to_string(), Value::Integer(1));
814            value_map.insert("name".to_string(), Value::Text("DefaultUser".to_string()));
815
816            self.storage
817                .put(table_id, row_key, map_to_value(value_map))
818                .await?;
819            inserted_count = 1;
820        }
821
822        Ok(QueryResult {
823            rows: vec![],
824            rows_affected: inserted_count,
825            execution_time_ms: 0,
826            metadata: super::result::QueryMetadata::default(),
827        })
828    }
829
830    /// Execute CREATE TABLE operation (placeholder — DDL isn't persisted yet).
831    async fn execute_create_table_operation(&self, _plan: &QueryPlan) -> Result<QueryResult> {
832        Ok(QueryResult {
833            rows: vec![],
834            rows_affected: 0,
835            execution_time_ms: 0,
836            metadata: super::result::QueryMetadata::default(),
837        })
838    }
839}
840
841/// Build a `Value::Map` from a string-keyed map for storage writes.
842#[cfg(feature = "experimental")]
843fn map_to_value(map: HashMap<String, Value>) -> Value {
844    Value::Map(map.into_iter().map(|(k, v)| (Value::Text(k), v)).collect())
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::Config;
851    use std::sync::Arc;
852    use tempfile::TempDir;
853
854    /// Construct a fresh executor against a temporary storage root.
855    async fn make_executor() -> (TempDir, QueryExecutor, Config) {
856        let temp_dir = TempDir::new().unwrap();
857        let config = Config::default();
858        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
859        let storage = Arc::new(
860            crate::storage::StorageEngine::open(
861                temp_dir.path(),
862                &config,
863                platform,
864                #[cfg(feature = "state_machine")]
865                None,
866            )
867            .await
868            .unwrap(),
869        );
870        let schema = Arc::new(
871            crate::schema::SchemaManager::new(temp_dir.path())
872                .await
873                .unwrap(),
874        );
875        let executor = QueryExecutor::new(storage, schema, &config);
876        (temp_dir, executor, config)
877    }
878
879    #[tokio::test]
880    async fn test_query_executor_creation() {
881        let (_tmp, executor, config) = make_executor().await;
882        assert_eq!(
883            executor._config.query.query_parallelism,
884            config.query.query_parallelism
885        );
886    }
887
888    #[tokio::test]
889    async fn test_value_comparison() {
890        let (_tmp, executor, _) = make_executor().await;
891
892        let result = executor
893            .compare_values(&Value::Integer(10), &Value::Integer(20))
894            .unwrap();
895        assert_eq!(result, Ordering::Less);
896
897        let result = executor
898            .compare_values(
899                &Value::Text("apple".to_string()),
900                &Value::Text("banana".to_string()),
901            )
902            .unwrap();
903        assert_eq!(result, Ordering::Less);
904    }
905
906    #[tokio::test]
907    async fn test_condition_evaluation() {
908        let (_tmp, executor, _) = make_executor().await;
909
910        let mut row_values = HashMap::new();
911        row_values.insert("id".to_string(), Value::Integer(1));
912        row_values.insert("name".to_string(), Value::Text("test".to_string()));
913        let row = QueryRow::with_values(RowKey::new(vec![1]), row_values);
914
915        let condition = Condition {
916            column: "id".to_string(),
917            operator: ComparisonOperator::Equal,
918            value: Value::Integer(1),
919        };
920        assert!(executor.evaluate_condition(&row, &condition).unwrap());
921
922        let condition = Condition {
923            column: "name".to_string(),
924            operator: ComparisonOperator::Like,
925            value: Value::Text("test".to_string()),
926        };
927        assert!(executor.evaluate_condition(&row, &condition).unwrap());
928    }
929
930    // -- indexes_used access-path reporting (issue #760, Epic #756) --------
931
932    use super::super::planner::{IndexSelection, IndexType};
933
934    /// Build a minimal plan with the given type and selected indexes.
935    fn plan_with(
936        plan_type: super::super::planner::PlanType,
937        selected_indexes: Vec<IndexSelection>,
938    ) -> QueryPlan {
939        QueryPlan {
940            plan_type,
941            table: None,
942            estimated_cost: 0.0,
943            estimated_rows: 0,
944            selected_indexes,
945            steps: Vec::new(),
946            hints: super::super::planner::QueryHints::default(),
947        }
948    }
949
950    fn primary_index() -> IndexSelection {
951        IndexSelection {
952            index_name: "PRIMARY".to_string(),
953            columns: vec!["id".to_string()],
954            selectivity: 0.1,
955            index_type: IndexType::Primary,
956        }
957    }
958
959    /// A point lookup resolves the partition via the partition index
960    /// (Index.db / Summary.db) — it MUST report the index it used, not "scan".
961    #[test]
962    fn test_indexes_used_point_lookup_reports_partition_index() {
963        let plan = plan_with(
964            super::super::planner::PlanType::PointLookup,
965            vec![primary_index()],
966        );
967        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["PRIMARY"]);
968    }
969
970    /// A full table scan reports the explicit "scan" marker (we picked the
971    /// marker over an empty list so EXPLAIN output is unambiguous).
972    #[test]
973    fn test_indexes_used_table_scan_reports_scan_marker() {
974        let plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
975        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
976    }
977
978    /// Regression (roborev job 40): a TableScan plan that is actually an INSERT
979    /// or a CREATE TABLE never calls `storage.scan`, so it must NOT report the
980    /// "scan" access path. `execute()` special-cases these before
981    /// `execute_table_scan`; `indexes_used_for` must mirror that.
982    #[test]
983    fn test_indexes_used_insert_and_ddl_table_scan_report_no_scan() {
984        use super::super::planner::{ParallelizationInfo, StepType};
985
986        // INSERT: a TableScan plan carrying an Insert step.
987        let insert_step = ExecutionStep {
988            step_type: StepType::Insert,
989            columns: Vec::new(),
990            conditions: Vec::new(),
991            cost: 0.0,
992            parallelization: ParallelizationInfo {
993                can_parallelize: false,
994                suggested_threads: 1,
995                partition_key: None,
996            },
997        };
998        let mut insert_plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
999        insert_plan.steps = vec![insert_step];
1000        assert!(
1001            QueryExecutor::indexes_used_for(&insert_plan).is_empty(),
1002            "INSERT must not report a scan access path"
1003        );
1004
1005        // CREATE TABLE: empty steps, a target table, zero estimated rows.
1006        let mut ddl_plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1007        ddl_plan.table = Some(TableId::new("t"));
1008        ddl_plan.estimated_rows = 0;
1009        assert!(
1010            QueryExecutor::indexes_used_for(&ddl_plan).is_empty(),
1011            "CREATE TABLE must not report a scan access path"
1012        );
1013    }
1014
1015    /// Range scans degrade to a sequential scan in the executor → "scan".
1016    #[test]
1017    fn test_indexes_used_range_scan_reports_scan_marker() {
1018        let plan = plan_with(super::super::planner::PlanType::RangeScan, Vec::new());
1019        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1020    }
1021
1022    /// IndexScan on a Primary/Bloom index does a real point lookup → report
1023    /// the index name. (These executor paths call `storage.get`.)
1024    #[test]
1025    fn test_indexes_used_index_scan_primary_reports_index() {
1026        let plan = plan_with(
1027            super::super::planner::PlanType::IndexScan,
1028            vec![primary_index()],
1029        );
1030        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["PRIMARY"]);
1031    }
1032
1033    /// IndexScan on a Secondary index currently degrades to a full scan in the
1034    /// executor (the secondary lookup is not yet wired up). Report "scan" —
1035    /// reporting the index would be fabrication.
1036    #[test]
1037    fn test_indexes_used_index_scan_secondary_reports_scan() {
1038        let secondary = IndexSelection {
1039            index_name: "idx_name".to_string(),
1040            columns: vec!["name".to_string()],
1041            selectivity: 0.1,
1042            index_type: IndexType::Secondary,
1043        };
1044        let plan = plan_with(super::super::planner::PlanType::IndexScan, vec![secondary]);
1045        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1046    }
1047
1048    // -- parallelization metadata truthfulness (issue #1691) --------------
1049
1050    /// A step that the planner marked parallelizable.
1051    fn parallelizable_step() -> ExecutionStep {
1052        use super::super::planner::{ParallelizationInfo, StepType};
1053        ExecutionStep {
1054            step_type: StepType::Scan,
1055            columns: Vec::new(),
1056            conditions: Vec::new(),
1057            cost: 0.0,
1058            parallelization: ParallelizationInfo {
1059                can_parallelize: true,
1060                suggested_threads: 8,
1061                partition_key: None,
1062            },
1063        }
1064    }
1065
1066    /// Issue #1691: the TableScan path now runs through a SINGLE bounded
1067    /// `scan_stream` pass, not N parallel workers. Even when the planner
1068    /// suggested 8 threads, the reported metadata must be truthful:
1069    /// `threads_used == 1` and `effective == false`.
1070    #[test]
1071    fn test_parallelization_table_scan_reports_single_threaded() {
1072        let mut plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1073        plan.steps = vec![parallelizable_step()];
1074
1075        let info = QueryExecutor::parallelization_for(&plan)
1076            .expect("a parallelizable step should still yield metadata");
1077        assert_eq!(info.threads_used, 1);
1078        assert!(!info.effective);
1079        assert!(info.partitions.is_empty());
1080    }
1081
1082    /// A plan with no parallelizable step yields no parallelization metadata.
1083    #[test]
1084    fn test_parallelization_absent_when_no_step_parallelizes() {
1085        let plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1086        assert!(QueryExecutor::parallelization_for(&plan).is_none());
1087    }
1088
1089    /// Non-scan plan types still surface the planner's suggested thread count as
1090    /// effective — only the retired table-scan path is neutralized.
1091    #[test]
1092    fn test_parallelization_non_scan_reports_planner_threads() {
1093        let mut plan = plan_with(super::super::planner::PlanType::Aggregation, Vec::new());
1094        plan.steps = vec![parallelizable_step()];
1095
1096        let info = QueryExecutor::parallelization_for(&plan)
1097            .expect("a parallelizable step should yield metadata");
1098        assert_eq!(info.threads_used, 8);
1099        assert!(info.effective);
1100    }
1101
1102    #[tokio::test]
1103    async fn test_condition_to_row_key_mapping() {
1104        let (_tmp, executor, _) = make_executor().await;
1105
1106        let id_condition = Condition {
1107            column: "id".to_string(),
1108            operator: ComparisonOperator::Equal,
1109            value: Value::Integer(42),
1110        };
1111        let key = executor
1112            .condition_to_row_key(&id_condition)
1113            .expect("id condition key");
1114        assert_eq!(std::str::from_utf8(key.as_bytes()).unwrap(), "user_key_42");
1115
1116        let name_condition = Condition {
1117            column: "username".to_string(),
1118            operator: ComparisonOperator::Equal,
1119            value: Value::Text("carol".to_string()),
1120        };
1121        let key = executor
1122            .condition_to_row_key(&name_condition)
1123            .expect("fallback key");
1124        assert_eq!(key.as_bytes(), b"carol");
1125    }
1126
1127    /// Issue #1334 / roborev H1: the offset-read placeholder
1128    /// (`data_access::read_value_at_offset`) surfaces its raw bytes to
1129    /// SELECT/export through `get()` → `storage_data_to_query_row` as a single
1130    /// column keyed `"data"` — exactly the behaviour a bare `Value::Blob` had
1131    /// pre-#1334. The producer now emits explicit `ScanRow::RawRow` provenance;
1132    /// this pins that a `RawRow` keeps surfacing the value under `"data"`, while
1133    /// the equivalent `ScanRow::Marker` is SUPPRESSED (drops the blob to an
1134    /// id-only fallback) — the regression a `Marker` here would cause.
1135    #[tokio::test]
1136    async fn offset_read_row_surfaces_data_marker_is_suppressed() {
1137        let (_tmp, executor, _) = make_executor().await;
1138        let key = RowKey::new(vec![7]);
1139        let raw = vec![0xde, 0xad, 0xbe, 0xef];
1140
1141        // The fixed producer output: the raw fallback surfaces its bytes as "data".
1142        let live = ScanRow::RawRow(raw.clone());
1143        let row = executor
1144            .storage_data_to_query_row(live, &key)
1145            .expect("raw offset-read row must convert");
1146        assert_eq!(
1147            row.values.get("data"),
1148            Some(&Value::Blob(raw.clone())),
1149            "a live offset/indexed read must surface its raw value as the \"data\" column"
1150        );
1151
1152        // Marker (the pre-fix producer output): the blob is dropped; the row
1153        // falls back to an id-only shape with NO "data" column — proving a Marker
1154        // here would lose data that previously reached SELECT/export.
1155        let marker = ScanRow::Marker(Value::Blob(raw));
1156        let suppressed = executor
1157            .storage_data_to_query_row(marker, &key)
1158            .expect("marker row must still convert");
1159        assert!(
1160            !suppressed.values.contains_key("data"),
1161            "a Marker must NOT surface the raw blob (this is the suppression the fix avoids)"
1162        );
1163    }
1164
1165    // -- retirement of execute_parallel_table_scan (issue #1691) -----------
1166
1167    /// A `TableScan` plan whose step requested parallelization (`suggested_threads`
1168    /// = 4, mirroring the retired path's default worker count). It routes to
1169    /// `execute_table_scan` (no INSERT step, non-empty steps ⇒ not CREATE TABLE)
1170    /// and takes the `can_parallelize` branch.
1171    fn parallelizable_table_scan_plan() -> QueryPlan {
1172        use super::super::planner::{ParallelizationInfo, PlanType, QueryHints, StepType};
1173        QueryPlan {
1174            plan_type: PlanType::TableScan,
1175            table: Some(TableId::new("t")),
1176            estimated_cost: 0.0,
1177            estimated_rows: 1,
1178            selected_indexes: Vec::new(),
1179            steps: vec![ExecutionStep {
1180                step_type: StepType::Scan,
1181                columns: Vec::new(),
1182                conditions: Vec::new(),
1183                cost: 0.0,
1184                parallelization: ParallelizationInfo {
1185                    can_parallelize: true,
1186                    suggested_threads: 4,
1187                    partition_key: None,
1188                },
1189            }],
1190            hints: QueryHints::default(),
1191        }
1192    }
1193
1194    /// Issue #1691 (verification-first): the parallelizable `TableScan` branch must
1195    /// issue EXACTLY ONE whole-table scan pass. The retired
1196    /// `execute_parallel_table_scan` spawned `suggested_threads` (4) workers, each
1197    /// re-running the identical full `storage.scan` — four duplicate whole-table
1198    /// passes for a single plan. With the retirement, the branch routes through the
1199    /// bounded streaming `scan_stream`, a single pass.
1200    ///
1201    /// This is RED on the pre-fix routing (the counter reads 4) and GREEN after
1202    /// (reads 1); it needs no on-disk data because the counter observes scan
1203    /// *initiations*, which the retired path made 4× even over an empty table.
1204    /// The counter is thread-local and this is a current-thread `#[tokio::test]`,
1205    /// so the scan runs on this thread and other parallel tests cannot pollute it.
1206    #[tokio::test]
1207    async fn table_scan_parallel_branch_issues_one_whole_table_pass() {
1208        let (_tmp, executor, _) = make_executor().await;
1209        crate::storage::reset_table_scan_calls();
1210
1211        let plan = parallelizable_table_scan_plan();
1212        let _ = executor.execute(&plan).await.expect("table scan executes");
1213
1214        assert_eq!(
1215            crate::storage::table_scan_call_count(),
1216            1,
1217            "the parallelizable TableScan branch must issue exactly ONE whole-table \
1218             scan pass; the retired execute_parallel_table_scan issued one per worker (4×)"
1219        );
1220    }
1221
1222    /// The bounded streaming branch drains its results correctly (no rows dropped
1223    /// by the `scan_stream` backpressure discipline) and still issues one pass.
1224    /// Over an empty table this is the trivial-but-load-bearing lower bound: the
1225    /// branch returns an empty result set and never fans out into multiple scans.
1226    #[tokio::test]
1227    async fn streaming_scan_branch_returns_all_rows_bounded() {
1228        let (_tmp, executor, _) = make_executor().await;
1229        crate::storage::reset_table_scan_calls();
1230
1231        let plan = parallelizable_table_scan_plan();
1232        let result = executor.execute(&plan).await.expect("table scan executes");
1233
1234        assert!(result.rows.is_empty(), "empty table yields no rows");
1235        assert_eq!(
1236            crate::storage::table_scan_call_count(),
1237            1,
1238            "the streaming drain must not re-issue the scan"
1239        );
1240    }
1241}