Skip to main content

cqlite_core/query/
engine.rs

1//! Query engine implementation for CQLite
2//!
3//! This module provides the main query engine that coordinates between
4//! parsing, planning, and execution of CQL queries.
5
6// CQL (Cassandra Query Language) Reference:
7// https://cassandra.apache.org/doc/latest/cassandra/developing/cql/cql_singlefile.html
8//
9// This implements CQL v3.4.3+ for Apache Cassandra 5.0+
10// CQL is NOT SQL - it's a query language specifically designed for Cassandra's distributed architecture.
11
12use super::{
13    executor::{QueryExecutor, QueryResult},
14    parser::QueryParser,
15    planner::QueryPlanner,
16    prepared::PreparedQuery,
17    result::{QueryResultIterator, StreamingConfig},
18    QueryStats,
19};
20
21use super::engine_stats::AtomicQueryStats;
22#[cfg(feature = "state_machine")]
23use super::{
24    select_executor::SelectExecutor,
25    select_optimizer::{OptimizedQueryPlan, SelectOptimizer},
26    select_parser,
27};
28use crate::{
29    memory::MemoryManager, schema::SchemaManager, storage::StorageEngine, Config, Error, Result,
30    Value,
31};
32use dashmap::DashMap;
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::Arc;
35use std::time::Instant;
36
37/// Query cache entry
38///
39/// Crate-internal plan-cache entry with no external consumers, so the
40/// `hit_count: AtomicU64` field (issue #1595) is kept off the public semver
41/// surface by keeping this type `pub(crate)`.
42#[derive(Debug)]
43pub(crate) struct QueryCacheEntry {
44    /// Parsed query
45    pub parsed_query: super::ParsedQuery,
46    /// Query plan
47    pub plan: super::planner::QueryPlan,
48    /// Cache timestamp
49    pub cached_at: Instant,
50    /// Hit count. Lock-free (issue #1595): a plan-cache HIT bumps this via a
51    /// shard READ lock (`DashMap::get`) + relaxed `fetch_add`, so concurrent hits
52    /// to the same shard no longer contend on a write lock.
53    pub hit_count: AtomicU64,
54}
55
56// `AtomicU64` is not `Clone`, so the previous `#[derive(Clone)]` no longer
57// applies; preserve the public `Clone` surface by hand, snapshotting the counter
58// with a relaxed load (issue #1595).
59impl Clone for QueryCacheEntry {
60    fn clone(&self) -> Self {
61        Self {
62            parsed_query: self.parsed_query.clone(),
63            plan: self.plan.clone(),
64            cached_at: self.cached_at,
65            hit_count: AtomicU64::new(self.hit_count.load(Ordering::Relaxed)),
66        }
67    }
68}
69
70/// Schema availability status for diagnostic purposes
71#[derive(Debug, Clone)]
72pub enum SchemaStatus {
73    /// Schema is available and ready for queries
74    Available { keyspace: String, table: String },
75    /// Schema not found in registry
76    Missing { table: String, reason: String },
77    /// Schema extraction failed from SSTable
78    ExtractionFailed {
79        table: String,
80        cause: String,
81        suggestion: String,
82    },
83}
84
85/// Query engine with caching and statistics
86#[derive(Debug)]
87pub struct QueryEngine {
88    /// Query parser
89    parser: QueryParser,
90    /// Query planner
91    planner: QueryPlanner,
92    /// Query executor
93    executor: QueryExecutor,
94    /// Schema manager reference
95    schema_manager: Arc<SchemaManager>,
96    /// Advanced SELECT optimizer. `Arc` so prepared SELECTs can share the same
97    /// instance and reach the partition-targeted fast path (Issue #961).
98    #[cfg(feature = "state_machine")]
99    select_optimizer: Arc<SelectOptimizer>,
100    /// Advanced SELECT executor (shared with prepared SELECTs, Issue #961).
101    #[cfg(feature = "state_machine")]
102    select_executor: Arc<SelectExecutor>,
103    /// Prepared statement cache
104    prepared_cache: DashMap<String, Arc<PreparedQuery>>,
105    /// Query plan cache
106    plan_cache: DashMap<String, QueryCacheEntry>,
107    /// Issue #1587 (E5): plan cache for the modern (state_machine) SELECT path,
108    /// keyed by the exact CQL text. The optimized plan is a pure function of the
109    /// literal CQL, so a repeated identical ad-hoc SELECT reuses the plan instead
110    /// of re-parsing + re-optimizing. Kept separate from `plan_cache` (which
111    /// holds legacy `QueryPlan`s and feeds `CacheStats`) so neither disturbs the
112    /// other. Value is `(inserted_at, plan)` for the same simple-LRU eviction.
113    #[cfg(feature = "state_machine")]
114    select_plan_cache: DashMap<String, (Instant, Arc<OptimizedQueryPlan>)>,
115    /// Query statistics. Lock-free atomic counters (issue #1595): counters are
116    /// bumped with relaxed atomics instead of a `RwLock` taken 2–3× per query.
117    stats: AtomicQueryStats,
118    /// Configuration
119    config: Config,
120}
121
122impl QueryEngine {
123    /// Create a new query engine
124    pub fn new(
125        storage: Arc<StorageEngine>,
126        schema: Arc<SchemaManager>,
127        _memory: Arc<MemoryManager>,
128        config: &Config,
129    ) -> Result<Self> {
130        let parser = QueryParser::new(config);
131        let planner = QueryPlanner::new(schema.clone(), config);
132        let executor = QueryExecutor::new(storage.clone(), schema.clone(), config);
133
134        // Initialize advanced SELECT components
135        #[cfg(feature = "state_machine")]
136        let select_optimizer = Arc::new(SelectOptimizer::new(schema.clone(), storage.clone()));
137        // Issue #1582 (D6): wire the byte-bounded result budget from config so
138        // the `max_result_bytes` knob is load-bearing on the materializing path.
139        #[cfg(feature = "state_machine")]
140        let select_executor = Arc::new(
141            SelectExecutor::new(schema.clone(), storage)
142                .with_max_result_bytes(
143                    usize::try_from(config.query.max_result_bytes).unwrap_or(usize::MAX),
144                )
145                .with_max_result_rows(
146                    usize::try_from(config.query.max_result_rows).unwrap_or(usize::MAX),
147                )
148                // Issue #1918: wire the read-path forcing knob from config so the
149                // `forced_read_path` field (config over env over auto) is
150                // load-bearing on the read path.
151                .with_forced_read_path(config.query.forced_read_path),
152        );
153
154        Ok(Self {
155            parser,
156            planner,
157            executor,
158            schema_manager: schema,
159            #[cfg(feature = "state_machine")]
160            select_optimizer,
161            #[cfg(feature = "state_machine")]
162            select_executor,
163            prepared_cache: DashMap::new(),
164            plan_cache: DashMap::new(),
165            #[cfg(feature = "state_machine")]
166            select_plan_cache: DashMap::new(),
167            stats: AtomicQueryStats::default(),
168            config: config.clone(),
169        })
170    }
171
172    /// Enforce the byte-bounded result budget (issue #1582 / D6) on a result
173    /// materialized by the LEGACY [`QueryExecutor`] — non-SELECT statements and
174    /// any cached legacy SELECT plan reused via the plan-cache HIT branch. Since
175    /// issue #1750 ad-hoc SELECTs route through the modern `SelectExecutor` (which
176    /// budgets itself); this guard covers the remaining legacy return sites,
177    /// INCLUDING a plan-cache HIT (roborev #1582 D6). Reuses the SAME estimator +
178    /// enforcement as the optimizer path. Applied exactly once at each site.
179    fn enforce_legacy_result_budget(&self, result: &QueryResult) -> Result<()> {
180        super::result_budget::enforce_materialized_rows(
181            &result.rows,
182            usize::try_from(self.config.query.max_result_bytes).unwrap_or(usize::MAX),
183            usize::try_from(self.config.query.max_result_rows).unwrap_or(usize::MAX),
184        )
185        .inspect_err(|e| {
186            self.inc_error_queries();
187            crate::observability::record_error(e, "query");
188        })
189    }
190
191    /// Increment the total queries counter (lock-free, issue #1595)
192    fn inc_total_queries(&self) {
193        self.stats.record_query();
194    }
195
196    /// Increment the error queries counter (lock-free, issue #1595)
197    fn inc_error_queries(&self) {
198        self.stats.record_error();
199    }
200
201    /// Record a plan-cache hit (lock-free, issue #1595). `cache_hit_ratio` is
202    /// derived at read time as `cache_hits / total_queries`.
203    fn record_cache_hit(&self) {
204        self.stats.record_cache_hit();
205    }
206
207    /// Execute a CQL query
208    ///
209    /// This is the parent of the query span tree (epic #1031, issue #1035): the
210    /// `query.execute` span created here is the context every read-path span
211    /// (issue #1034) and SELECT sub-span nests under. Bounded span attributes
212    /// (plan type, access path, rows returned) are recorded once the result is
213    /// known via [`Self::update_execution_stats`]; the query text is never
214    /// attached.
215    #[tracing::instrument(
216        name = "query.execute",
217        skip(self, cql),
218        fields(
219            cqlite.query.plan_type = tracing::field::Empty,
220            cqlite.query.access_path = tracing::field::Empty,
221            cqlite.query.rows = tracing::field::Empty,
222        )
223    )]
224    pub async fn execute(&self, cql: &str) -> Result<QueryResult> {
225        let start_time = Instant::now();
226        self.inc_total_queries();
227
228        // Route ALL SELECT statements through the modern optimizer +
229        // `SelectExecutor` (issue #1750). It populates `metadata.columns`, applies
230        // projection, reconstructs the partition-key column, and selects the
231        // partition-targeted fast path (#949/#956) from parsed query structure +
232        // schema — never from a substring/token-count guess. The retired
233        // `is_simple_id_lookup` fork (`WHERE id =` substring + ≤ 8-token check)
234        // diverted short point reads to the legacy `QueryExecutor`, returning
235        // column-less results and skipping projection — a no-heuristics mandate
236        // violation (#28) that flipped behavior with the PK name.
237        let trimmed_cql = cql.trim().to_uppercase();
238        if trimmed_cql.starts_with("SELECT") {
239            return self.execute_select_query(cql, start_time).await;
240        }
241
242        // Check plan cache first for non-SELECT queries. Issue #1595: serve the
243        // HIT under a shard READ lock (`DashMap::get`), bump the lock-free
244        // `hit_count`, clone the plan out, and DROP the guard before the `.await`
245        // so no shard lock is held across execution and concurrent hits do not
246        // contend on a write lock.
247        let cached_plan = self.plan_cache.get(cql).map(|entry| {
248            entry.hit_count.fetch_add(1, Ordering::Relaxed);
249            entry.plan.clone()
250        });
251        if let Some(plan) = cached_plan {
252            self.record_cache_hit();
253
254            let mut result =
255                crate::observability::record_result("query", self.executor.execute(&plan).await)?;
256            // Issue #1582 (D6): the LEGACY point-lookup path bypasses the
257            // SelectExecutor's byte budget; enforce it on the plan-cache HIT
258            // return too (roborev finding) so a single very wide row cannot
259            // exceed `max_result_bytes` without raising `ResultTooLarge`.
260            self.enforce_legacy_result_budget(&result)?;
261            self.update_execution_stats(&mut result, start_time);
262            return Ok(result);
263        }
264
265        let parsed_query = self.parser.parse(cql).inspect_err(|e| {
266            self.inc_error_queries();
267            crate::observability::record_error(e, "query");
268        })?;
269        let plan =
270            crate::observability::record_result("query", self.planner.plan(&parsed_query).await)?;
271
272        if self.config.query.query_cache_size.unwrap_or(0) > 0 {
273            self.cache_query_plan(cql, parsed_query, plan.clone());
274        }
275
276        let mut result =
277            crate::observability::record_result("query", self.executor.execute(&plan).await)?;
278        // Issue #1582 (D6): the LEGACY point-lookup path bypasses the
279        // SelectExecutor's byte budget; enforce it on the cold (cache-miss)
280        // return, matching the plan-cache-hit return above.
281        self.enforce_legacy_result_budget(&result)?;
282        self.update_execution_stats(&mut result, start_time);
283        Ok(result)
284    }
285
286    /// Execute a CQL query with streaming results (Issue #280)
287    ///
288    /// Returns a `QueryResultIterator` that yields rows incrementally via a bounded
289    /// channel, enabling memory-efficient processing of large result sets.
290    ///
291    /// # Arguments
292    ///
293    /// * `cql` - The CQL query string to execute (must be a SELECT statement)
294    /// * `config` - Streaming configuration (buffer size, chunk hints)
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if:
299    /// - Query is not a SELECT statement
300    /// - SQL syntax is invalid
301    /// - Query execution fails
302    ///
303    /// # Memory Budget
304    ///
305    /// The streaming approach stays within the 128MB target by using bounded channels
306    /// and processing rows incrementally rather than materializing all results.
307    #[cfg(feature = "state_machine")]
308    pub async fn execute_streaming(
309        &self,
310        cql: &str,
311        config: StreamingConfig,
312    ) -> Result<QueryResultIterator> {
313        self.inc_total_queries();
314
315        if !cql.trim().to_uppercase().starts_with("SELECT") {
316            return Err(Error::query_execution(
317                "Streaming execution only supports SELECT queries",
318            ));
319        }
320
321        let select_statement =
322            select_parser::parse_select(cql).inspect_err(|_| self.inc_error_queries())?;
323        let optimized_plan = self.select_optimizer.optimize(select_statement).await?;
324
325        self.select_executor
326            .execute_streaming(optimized_plan, config)
327            .await
328    }
329
330    /// Execute a SELECT query using the advanced parser and optimizer
331    async fn execute_select_query(&self, cql: &str, start_time: Instant) -> Result<QueryResult> {
332        // Check plan cache first for SELECT queries too. Issue #1595: resolve the
333        // outcome under a shard READ lock (`DashMap::get`) — bumping the lock-free
334        // `hit_count` and cloning the plan for a reusable entry — then DROP the
335        // guard before acting, so the `.await` holds no shard lock and `remove`
336        // never runs while a `get` guard on the same shard is held.
337        enum HitOutcome {
338            Reuse(super::planner::QueryPlan),
339            Placeholder,
340            Miss,
341        }
342        let outcome = match self.plan_cache.get(cql) {
343            Some(entry) if entry.plan.table.is_some() => {
344                entry.hit_count.fetch_add(1, Ordering::Relaxed);
345                HitOutcome::Reuse(entry.plan.clone())
346            }
347            Some(_) => HitOutcome::Placeholder,
348            None => HitOutcome::Miss,
349        };
350        match outcome {
351            HitOutcome::Reuse(plan) => {
352                self.record_cache_hit();
353
354                let mut result = crate::observability::record_result(
355                    "query",
356                    self.executor.execute(&plan).await,
357                )?;
358                // Issue #1582 (D6): this cached SELECT plan is served by the
359                // LEGACY executor, which bypasses the SelectExecutor's byte
360                // budget; enforce it on this plan-cache HIT return too.
361                self.enforce_legacy_result_budget(&result)?;
362                self.update_execution_stats(&mut result, start_time);
363                return Ok(result);
364            }
365            HitOutcome::Placeholder => {
366                // Placeholder plans without table information are not reusable; drop them.
367                self.plan_cache.remove(cql);
368            }
369            HitOutcome::Miss => {}
370        }
371
372        #[cfg(not(feature = "state_machine"))]
373        return Err(Error::query_execution(
374            "Advanced SELECT parsing requires state_machine feature",
375        ));
376
377        #[cfg(feature = "state_machine")]
378        {
379            // Issue #1587 (E5): reuse a previously-optimized plan for the exact
380            // same CQL text. The optimized plan is a pure function of the literal
381            // CQL, so this is byte-for-byte equivalent to re-parsing +
382            // re-optimizing — it only skips the redundant work.
383            let optimized_plan: Arc<OptimizedQueryPlan> =
384                if let Some(entry) = self.select_plan_cache.get(cql) {
385                    self.record_cache_hit();
386                    Arc::clone(&entry.value().1)
387                } else {
388                    let select_statement = select_parser::parse_select(cql).inspect_err(|e| {
389                        self.inc_error_queries();
390                        crate::observability::record_error(e, "query");
391                    })?;
392                    let plan = Arc::new(crate::observability::record_result(
393                        "query",
394                        self.select_optimizer.optimize(select_statement).await,
395                    )?);
396                    self.cache_select_plan(cql, Arc::clone(&plan));
397                    plan
398                };
399            let mut result = crate::observability::record_result(
400                "query",
401                self.select_executor
402                    .execute((*optimized_plan).clone())
403                    .await,
404            )?;
405            self.update_execution_stats(&mut result, start_time);
406            Ok(result)
407        }
408    }
409
410    /// Insert an optimized SELECT plan into the modern plan cache, evicting the
411    /// oldest entry first when at capacity (simple LRU, matching the legacy
412    /// `cache_query_plan`). A configured `query_cache_size` of 0 disables it.
413    #[cfg(feature = "state_machine")]
414    fn cache_select_plan(&self, cql: &str, plan: Arc<OptimizedQueryPlan>) {
415        let cache_size = self.config.query.query_cache_size.unwrap_or(0);
416        if cache_size == 0 {
417            return;
418        }
419        if self.select_plan_cache.len() >= cache_size {
420            let oldest_key = self
421                .select_plan_cache
422                .iter()
423                .min_by_key(|entry| entry.value().0)
424                .map(|entry| entry.key().clone());
425            if let Some(key) = oldest_key {
426                self.select_plan_cache.remove(&key);
427            }
428        }
429        self.select_plan_cache
430            .insert(cql.to_string(), (Instant::now(), plan));
431    }
432
433    /// Execute a query with positional `?` parameters (Issue #961).
434    ///
435    /// The supplied `params` are bound, in source order, into the `?` placeholders
436    /// of the parsed statement *before* planning and execution, so the bound
437    /// values participate in partition-key classification, encoding, and typed
438    /// coercion. A `WHERE pk = ?` therefore engages the same partition-targeted
439    /// fast path (#949/#956) as the equivalent literal query.
440    ///
441    /// Binding is currently supported for SELECT statements only. A non-SELECT
442    /// CQL with parameters, or any use of named (`:name`) parameters, is rejected
443    /// with a clear error (named-parameter binding is intentionally out of scope:
444    /// the SELECT grammar only tokenizes positional `?`).
445    ///
446    /// # Routing parity with `execute` (Finding 1)
447    ///
448    /// When the parsed SELECT has **zero** bind markers and `params` is empty,
449    /// this delegates straight back to [`Self::execute`] so that a markerless
450    /// `execute_with_params(sql, &[])` is byte-for-byte equivalent to
451    /// `execute(sql)` — which since issue #1750 routes every literal SELECT
452    /// through the modern SELECT optimizer + executor. Only when markers are
453    /// present (`> 0`) is the statement bound and driven through that pipeline.
454    ///
455    /// Arity stays strict in both directions: markers `> 0` with a wrong
456    /// `params.len()` is an error, and markers `== 0` with a **non-empty**
457    /// `params` is also an error (a supplied parameter with no placeholder is a
458    /// caller bug). The latter matches [`SelectStatement::bind_parameters`]'s
459    /// contract and the documented strictness of this API.
460    pub async fn execute_with_params(&self, cql: &str, params: &[Value]) -> Result<QueryResult> {
461        let is_select = cql.trim().to_uppercase().starts_with("SELECT");
462
463        if !is_select {
464            // Non-SELECT: parameter binding is not supported. With no parameters
465            // this is just a normal statement, so defer to the regular path;
466            // with parameters it is an explicit, clear error.
467            if params.is_empty() {
468                return self.execute(cql).await;
469            }
470            self.inc_total_queries();
471            self.inc_error_queries();
472            return Err(Error::query_execution(
473                "Parameterized execution currently supports SELECT statements only",
474            ));
475        }
476
477        #[cfg(not(feature = "state_machine"))]
478        {
479            let _ = params;
480            self.inc_total_queries();
481            self.inc_error_queries();
482            return Err(Error::query_execution(
483                "Parameterized SELECT execution requires the state_machine feature",
484            ));
485        }
486
487        #[cfg(feature = "state_machine")]
488        {
489            // Parse once so we can count bind markers and decide routing. Parse
490            // failures here mirror `execute_select_query`, which would also fail.
491            let statement = select_parser::parse_select(cql).inspect_err(|_| {
492                self.inc_total_queries();
493                self.inc_error_queries();
494            })?;
495            let marker_count = statement.bind_marker_count();
496
497            // Finding 1: a markerless SELECT with no supplied params must route
498            // exactly like a literal `execute(cql)` (since #1750, the modern
499            // executor) so the two APIs cannot diverge. A marker-free statement
500            // with stray params is a caller bug: reject it for strict arity.
501            if marker_count == 0 {
502                if params.is_empty() {
503                    return self.execute(cql).await;
504                }
505                self.inc_total_queries();
506                self.inc_error_queries();
507                return Err(Error::query_execution(format!(
508                    "Parameter count mismatch: query has 0 bind marker(s), got {} parameter(s)",
509                    params.len()
510                )));
511            }
512
513            let start_time = Instant::now();
514            self.inc_total_queries();
515
516            // Markers present: bind through the SELECT pipeline. Arity is
517            // enforced by `bind_parameters` (too few / too many -> error). The
518            // bound statement reaches the same optimizer + executor as a literal
519            // `execute()`, so the partition-targeted fast path engages.
520            let mut statement = statement;
521            statement
522                .bind_parameters(params)
523                .inspect_err(|_| self.inc_error_queries())?;
524
525            let optimized_plan = self.select_optimizer.optimize(statement).await?;
526            let mut result = self.select_executor.execute(optimized_plan).await?;
527            self.update_execution_stats(&mut result, start_time);
528            Ok(result)
529        }
530    }
531
532    /// Prepare a query for repeated execution
533    pub async fn prepare(&self, cql: &str) -> Result<Arc<PreparedQuery>> {
534        if let Some(cached) = self.prepared_cache.get(cql) {
535            return Ok(cached.clone());
536        }
537
538        let parsed_query = self.parser.parse(cql)?;
539        let plan = self.planner.plan(&parsed_query).await?;
540
541        // Issue #961: when the prepared statement is a SELECT, attach the SELECT
542        // optimizer + executor pipeline so that `?` parameters are bound and the
543        // bound query reaches the partition-targeted fast path (#949/#956) — the
544        // same path a literal `execute()` takes. Non-SELECTs keep the legacy
545        // `QueryExecutor` plan path.
546        #[cfg(feature = "state_machine")]
547        let prepared = if cql.trim().to_uppercase().starts_with("SELECT") {
548            let statement = select_parser::parse_select(cql)?;
549            let marker_count = statement.bind_marker_count();
550            Arc::new(PreparedQuery::new_select(
551                parsed_query,
552                plan,
553                Arc::new(self.executor.clone()),
554                statement,
555                marker_count,
556                self.select_optimizer.clone(),
557                self.select_executor.clone(),
558            ))
559        } else {
560            Arc::new(PreparedQuery::new(
561                parsed_query,
562                plan,
563                Arc::new(self.executor.clone()),
564            ))
565        };
566
567        #[cfg(not(feature = "state_machine"))]
568        let prepared = Arc::new(PreparedQuery::new(
569            parsed_query,
570            plan,
571            Arc::new(self.executor.clone()),
572        ));
573
574        self.prepared_cache
575            .insert(cql.to_string(), prepared.clone());
576
577        Ok(prepared)
578    }
579
580    /// Execute a prepared query
581    pub async fn execute_prepared(
582        &self,
583        prepared: &PreparedQuery,
584        params: &[Value],
585    ) -> Result<QueryResult> {
586        let start_time = Instant::now();
587        self.inc_total_queries();
588
589        let mut result = prepared.execute(params).await?;
590        self.update_execution_stats(&mut result, start_time);
591        Ok(result)
592    }
593
594    /// Get query statistics
595    pub fn stats(&self) -> QueryStats {
596        self.stats.snapshot()
597    }
598
599    /// Clear all caches
600    pub fn clear_caches(&self) {
601        self.prepared_cache.clear();
602        self.plan_cache.clear();
603        #[cfg(feature = "state_machine")]
604        self.select_plan_cache.clear();
605    }
606
607    /// Clear prepared statement cache
608    pub fn clear_prepared_cache(&self) {
609        self.prepared_cache.clear();
610    }
611
612    /// Clear query plan cache
613    pub fn clear_plan_cache(&self) {
614        self.plan_cache.clear();
615        #[cfg(feature = "state_machine")]
616        self.select_plan_cache.clear();
617    }
618
619    /// Get cache statistics
620    pub fn cache_stats(&self) -> CacheStats {
621        CacheStats {
622            prepared_cache_size: self.prepared_cache.len(),
623            plan_cache_size: self.plan_cache.len(),
624            prepared_cache_hits: self.prepared_cache.len() as u64,
625            plan_cache_hits: self.plan_cache.len() as u64,
626        }
627    }
628
629    /// Optimize a query (return execution plan without executing)
630    pub async fn explain(&self, cql: &str) -> Result<ExplainResult> {
631        // Parse the query
632        let parsed_query = self.parser.parse(cql)?;
633
634        // Plan the query
635        let plan = self.planner.plan(&parsed_query).await?;
636
637        Ok(ExplainResult {
638            query_type: format!("{:?}", parsed_query.query_type),
639            plan_type: format!("{:?}", plan.plan_type),
640            estimated_cost: plan.estimated_cost,
641            estimated_rows: plan.estimated_rows,
642            selected_indexes: plan
643                .selected_indexes
644                .iter()
645                .map(|idx| format!("{} ({:?})", idx.index_name, idx.index_type))
646                .collect(),
647            execution_steps: plan
648                .steps
649                .iter()
650                .map(|step| {
651                    format!(
652                        "{:?}: {} (cost: {:.2})",
653                        step.step_type,
654                        step.columns.join(", "),
655                        step.cost
656                    )
657                })
658                .collect(),
659            parallelization_info: plan
660                .steps
661                .iter()
662                .filter(|step| step.parallelization.can_parallelize)
663                .map(|step| {
664                    format!(
665                        "Threads: {}, Partition: {:?}",
666                        step.parallelization.suggested_threads, step.parallelization.partition_key
667                    )
668                })
669                .collect(),
670        })
671    }
672
673    /// Analyze query performance
674    pub async fn analyze(&self, cql: &str) -> Result<AnalyzeResult> {
675        let start_time = Instant::now();
676
677        // Execute the query multiple times to get average performance
678        let mut execution_times = Vec::new();
679        let mut results = Vec::new();
680
681        for _ in 0..self.config.query.analyze_iterations.unwrap_or(5) {
682            let iter_start = Instant::now();
683            let result = self.execute(cql).await?;
684            execution_times.push(iter_start.elapsed());
685            results.push(result);
686        }
687
688        let total_time = start_time.elapsed();
689        let avg_time =
690            execution_times.iter().sum::<std::time::Duration>() / execution_times.len() as u32;
691        let no_times = || Error::query_execution("No execution times recorded for analysis");
692        let min_time = execution_times.iter().min().ok_or_else(no_times)?;
693        let max_time = execution_times.iter().max().ok_or_else(no_times)?;
694
695        // Calculate standard deviation
696        let variance = execution_times
697            .iter()
698            .map(|time| {
699                let diff = time.as_nanos() as f64 - avg_time.as_nanos() as f64;
700                diff * diff
701            })
702            .sum::<f64>()
703            / execution_times.len() as f64;
704        let std_dev = variance.sqrt();
705
706        Ok(AnalyzeResult {
707            iterations: execution_times.len(),
708            total_time_ms: total_time.as_millis() as u64,
709            avg_time_ms: avg_time.as_millis() as u64,
710            min_time_ms: min_time.as_millis() as u64,
711            max_time_ms: max_time.as_millis() as u64,
712            std_dev_ms: (std_dev / 1_000_000.0) as u64, // Convert from nanoseconds to milliseconds
713            avg_rows_returned: results.iter().map(|r| r.rows.len()).sum::<usize>() / results.len(),
714            cache_hit_ratio: self.stats().cache_hit_ratio,
715        })
716    }
717
718    /// Cache a query plan, evicting the oldest entry first if at capacity (simple LRU).
719    fn cache_query_plan(
720        &self,
721        cql: &str,
722        parsed_query: super::ParsedQuery,
723        plan: super::planner::QueryPlan,
724    ) {
725        let cache_size = self.config.query.query_cache_size.unwrap_or(0);
726        if cache_size == 0 {
727            return;
728        }
729
730        if self.plan_cache.len() >= cache_size {
731            let oldest_key = self
732                .plan_cache
733                .iter()
734                .min_by_key(|entry| entry.cached_at)
735                .map(|entry| entry.key().clone());
736            if let Some(key) = oldest_key {
737                self.plan_cache.remove(&key);
738            }
739        }
740
741        self.plan_cache.insert(
742            cql.to_string(),
743            QueryCacheEntry {
744                parsed_query,
745                plan,
746                cached_at: Instant::now(),
747                hit_count: AtomicU64::new(0),
748            },
749        );
750    }
751
752    /// Check if schema is available for a table
753    pub async fn has_schema_for_table(&self, table: &str) -> bool {
754        self.schema_manager.get_table_schema(table).await.is_ok()
755    }
756
757    /// Get detailed schema status for debugging
758    pub async fn schema_status(&self, table: &str) -> SchemaStatus {
759        match self.schema_manager.get_table_schema(table).await {
760            Ok(schema) => SchemaStatus::Available {
761                keyspace: schema.keyspace.clone(),
762                table: schema.table.clone(),
763            },
764            Err(Error::Schema(msg)) if msg.contains("not found") => {
765                SchemaStatus::Missing {
766                    table: table.to_string(),
767                    reason: msg,
768                }
769            }
770            Err(e) => SchemaStatus::ExtractionFailed {
771                table: table.to_string(),
772                cause: e.to_string(),
773                suggestion: "Verify SSTable files are valid Cassandra 5.0 format and Statistics.db contains SerializationHeader".to_string(),
774            },
775        }
776    }
777
778    /// Update execution statistics.
779    ///
780    /// This is the single chokepoint every materializing query path (cache hit,
781    /// parsed-plan, SELECT, parameterized, prepared) funnels through once its
782    /// result is known, so it is also where observability emits the end-to-end
783    /// query signals exactly once (issue #1035): the [`catalog::QUERY_DURATION`]
784    /// histogram and the [`catalog::QUERY_ROWS`] counter, both dimensioned by the
785    /// bounded access path the SELECT chose (when available). It also records the
786    /// bounded span attributes on the active `query.execute` span so the parent of
787    /// the read-path span tree is self-describing. Durations are reported in
788    /// seconds, per the catalog convention.
789    fn update_execution_stats(&self, result: &mut QueryResult, start_time: Instant) {
790        use crate::observability::{self as obs, catalog, AttrValue};
791
792        let execution_time = start_time.elapsed();
793        // Ensure any non-zero execution time is at least 1ms for reporting
794        result.execution_time_ms = if execution_time.is_zero() {
795            0
796        } else {
797            std::cmp::max(1, execution_time.as_millis() as u64)
798        };
799
800        // Bounded access-path dimension, sourced from the honest per-query signal
801        // the modern SelectExecutor attaches to the result (epic #951/#960). We
802        // CONSUME that existing label rather than reinventing it; `None` (legacy
803        // executor / non-SELECT) is reported as "unknown" to keep the dimension
804        // bounded without fabricating a path.
805        let access_path_label: &'static str = result
806            .metadata
807            .access_path
808            .as_ref()
809            .map(|p| p.label())
810            .unwrap_or("unknown");
811        let plan_type_label: &'static str = result
812            .metadata
813            .plan_info
814            .as_ref()
815            .map(|p| Self::plan_type_label(&p.plan_type))
816            .unwrap_or("unknown");
817
818        // Emit the end-to-end query metrics exactly once, here.
819        obs::record_histogram(
820            catalog::QUERY_DURATION,
821            execution_time.as_secs_f64(),
822            &[
823                (catalog::attr::SUBSYSTEM, AttrValue::StaticStr("query")),
824                (
825                    catalog::attr::ACCESS_PATH,
826                    AttrValue::StaticStr(access_path_label),
827                ),
828                (
829                    catalog::attr::PLAN_TYPE,
830                    AttrValue::StaticStr(plan_type_label),
831                ),
832            ],
833        );
834        obs::add_counter(
835            catalog::QUERY_ROWS,
836            result.rows.len() as u64,
837            &[
838                (
839                    catalog::attr::ACCESS_PATH,
840                    AttrValue::StaticStr(access_path_label),
841                ),
842                (
843                    catalog::attr::PLAN_TYPE,
844                    AttrValue::StaticStr(plan_type_label),
845                ),
846            ],
847        );
848
849        // Record bounded attributes on the active `query.execute` span (parent of
850        // the read-path span tree). Never attach the query text or key values.
851        let span = tracing::Span::current();
852        span.record(catalog::attr::PLAN_TYPE, plan_type_label);
853        span.record(catalog::attr::ACCESS_PATH, access_path_label);
854        span.record("cqlite.query.rows", result.rows.len());
855
856        // Lock-free (issue #1595): accumulate the execution time and rows into
857        // relaxed atomics. `avg_execution_time_us` is derived at read time as
858        // `exec_time_us_sum / total_queries`.
859        let new_time_us = execution_time.as_micros() as u64;
860        self.stats
861            .record_execution(new_time_us, result.rows_affected);
862    }
863
864    /// Map a `PlanInfo.plan_type` (an executor `Debug`-formatted plan family such
865    /// as `"TableScan"`) onto a bounded, lower-snake label suitable as a metric
866    /// dimension and span attribute (issue #1035). Falls back to `"other"` for
867    /// any value outside the known taxonomy so the dimension stays bounded.
868    fn plan_type_label(plan_type: &str) -> &'static str {
869        match plan_type {
870            "TableScan" => "table_scan",
871            "IndexScan" => "index_scan",
872            "PointLookup" => "point_lookup",
873            "RangeScan" => "range_scan",
874            "Join" => "join",
875            "Aggregation" => "aggregation",
876            "Subquery" => "subquery",
877            _ => "other",
878        }
879    }
880}
881
882/// Cache statistics
883#[derive(Debug, Clone)]
884pub struct CacheStats {
885    /// Number of prepared statements cached
886    pub prepared_cache_size: usize,
887    /// Number of query plans cached
888    pub plan_cache_size: usize,
889    /// Total prepared cache hits
890    pub prepared_cache_hits: u64,
891    /// Total plan cache hits
892    pub plan_cache_hits: u64,
893}
894
895/// Query explanation result
896#[derive(Debug, Clone)]
897pub struct ExplainResult {
898    /// Query type
899    pub query_type: String,
900    /// Plan type
901    pub plan_type: String,
902    /// Estimated cost
903    pub estimated_cost: f64,
904    /// Estimated rows
905    pub estimated_rows: u64,
906    /// Selected indexes
907    pub selected_indexes: Vec<String>,
908    /// Execution steps
909    pub execution_steps: Vec<String>,
910    /// Parallelization information
911    pub parallelization_info: Vec<String>,
912}
913
914/// Query analysis result
915#[derive(Debug, Clone)]
916pub struct AnalyzeResult {
917    /// Number of iterations
918    pub iterations: usize,
919    /// Total analysis time
920    pub total_time_ms: u64,
921    /// Average execution time
922    pub avg_time_ms: u64,
923    /// Minimum execution time
924    pub min_time_ms: u64,
925    /// Maximum execution time
926    pub max_time_ms: u64,
927    /// Standard deviation of execution times
928    pub std_dev_ms: u64,
929    /// Average rows returned
930    pub avg_rows_returned: usize,
931    /// Cache hit ratio
932    pub cache_hit_ratio: f64,
933}
934
935#[cfg(all(test, feature = "state_machine"))]
936mod tests {
937    use super::*;
938    use crate::Config;
939    use std::sync::Arc;
940    use tempfile::TempDir;
941
942    #[tokio::test]
943    async fn test_query_engine_creation() {
944        let temp_dir = TempDir::new().unwrap();
945        let config = Config::default();
946        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
947
948        let storage = Arc::new(
949            crate::storage::StorageEngine::open(
950                temp_dir.path(),
951                &config,
952                platform,
953                #[cfg(feature = "state_machine")]
954                None,
955            )
956            .await
957            .unwrap(),
958        );
959        let schema = Arc::new(
960            crate::schema::SchemaManager::new(temp_dir.path())
961                .await
962                .unwrap(),
963        );
964        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
965
966        let query_engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
967
968        assert_eq!(query_engine.stats().total_queries, 0);
969        assert_eq!(query_engine.cache_stats().prepared_cache_size, 0);
970        assert_eq!(query_engine.cache_stats().plan_cache_size, 0);
971    }
972
973    #[tokio::test]
974    #[ignore = "Hangs >60s; needs investigation - gated for M1"]
975    async fn test_query_caching() {
976        let temp_dir = TempDir::new().unwrap();
977        let mut config = Config::test_config();
978        config.query.query_cache_size = Some(10);
979
980        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
981        let storage = Arc::new(
982            crate::storage::StorageEngine::open(
983                temp_dir.path(),
984                &config,
985                platform,
986                #[cfg(feature = "state_machine")]
987                None,
988            )
989            .await
990            .unwrap(),
991        );
992        let schema = Arc::new(
993            crate::schema::SchemaManager::new(temp_dir.path())
994                .await
995                .unwrap(),
996        );
997        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
998
999        let query_engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
1000
1001        // Execute a query twice
1002        let cql = "SELECT * FROM users WHERE id = 1";
1003        let _ = query_engine.execute(cql).await;
1004        let _ = query_engine.execute(cql).await;
1005
1006        // Check that plan was cached
1007        assert_eq!(query_engine.cache_stats().plan_cache_size, 1);
1008
1009        // Check cache hit ratio
1010        let stats = query_engine.stats();
1011        assert!(stats.cache_hit_ratio > 0.0);
1012    }
1013
1014    #[tokio::test]
1015    #[cfg(feature = "state_machine")]
1016    async fn test_prepared_statements() {
1017        let temp_dir = TempDir::new().unwrap();
1018        let config = Config::default();
1019        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1020
1021        let storage = Arc::new(
1022            crate::storage::StorageEngine::open(
1023                temp_dir.path(),
1024                &config,
1025                platform,
1026                #[cfg(feature = "state_machine")]
1027                None,
1028            )
1029            .await
1030            .unwrap(),
1031        );
1032        let schema = Arc::new(
1033            crate::schema::SchemaManager::new(temp_dir.path())
1034                .await
1035                .unwrap(),
1036        );
1037        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
1038
1039        let query_engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
1040
1041        // Prepare a statement
1042        let cql = "SELECT * FROM users WHERE id = ?";
1043        let prepared = query_engine.prepare(cql).await.unwrap();
1044
1045        // Execute it with parameters
1046        let params = vec![Value::Integer(1)];
1047        let result = query_engine
1048            .execute_prepared(&prepared, &params)
1049            .await
1050            .unwrap();
1051
1052        // Check that result was generated
1053        assert!(result.execution_time_ms > 0);
1054
1055        // Check that statement was cached
1056        assert_eq!(query_engine.cache_stats().prepared_cache_size, 1);
1057    }
1058
1059    #[tokio::test]
1060    async fn test_query_explain() {
1061        let temp_dir = TempDir::new().unwrap();
1062        let config = Config::default();
1063        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1064
1065        let storage = Arc::new(
1066            crate::storage::StorageEngine::open(
1067                temp_dir.path(),
1068                &config,
1069                platform,
1070                #[cfg(feature = "state_machine")]
1071                None,
1072            )
1073            .await
1074            .unwrap(),
1075        );
1076        let schema = Arc::new(
1077            crate::schema::SchemaManager::new(temp_dir.path())
1078                .await
1079                .unwrap(),
1080        );
1081        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
1082
1083        let query_engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
1084
1085        // Explain a query
1086        let cql = "SELECT * FROM users WHERE id = 1";
1087        let explain_result = query_engine.explain(cql).await.unwrap();
1088
1089        assert_eq!(explain_result.query_type, "Select");
1090        assert!(explain_result.estimated_cost > 0.0);
1091        assert!(!explain_result.selected_indexes.is_empty());
1092        assert!(!explain_result.execution_steps.is_empty());
1093    }
1094
1095    #[tokio::test]
1096    #[cfg(feature = "state_machine")]
1097    async fn test_cache_eviction() {
1098        let temp_dir = TempDir::new().unwrap();
1099        let mut config = Config::default();
1100        config.query.query_cache_size = Some(2); // Very small cache
1101
1102        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1103        let storage = Arc::new(
1104            crate::storage::StorageEngine::open(
1105                temp_dir.path(),
1106                &config,
1107                platform,
1108                #[cfg(feature = "state_machine")]
1109                None,
1110            )
1111            .await
1112            .unwrap(),
1113        );
1114        let schema = Arc::new(
1115            crate::schema::SchemaManager::new(temp_dir.path())
1116                .await
1117                .unwrap(),
1118        );
1119        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
1120
1121        let query_engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
1122
1123        // Execute 3 different queries. Since #1750 these route through the modern
1124        // SELECT executor; their plans live in `select_plan_cache`.
1125        let _ = query_engine
1126            .execute("SELECT * FROM users WHERE id = 1")
1127            .await;
1128        let _ = query_engine
1129            .execute("SELECT * FROM users WHERE id = 2")
1130            .await;
1131        let _ = query_engine
1132            .execute("SELECT * FROM users WHERE id = 3")
1133            .await;
1134
1135        // Cache should only have 2 entries due to eviction (simple LRU).
1136        assert_eq!(query_engine.select_plan_cache.len(), 2);
1137    }
1138
1139    #[tokio::test]
1140    #[cfg(feature = "state_machine")]
1141    async fn test_schema_validation_api() {
1142        let temp_dir = TempDir::new().unwrap();
1143        let config = Config::default();
1144        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1145
1146        let storage = Arc::new(
1147            crate::storage::StorageEngine::open(
1148                temp_dir.path(),
1149                &config,
1150                platform,
1151                #[cfg(feature = "state_machine")]
1152                None,
1153            )
1154            .await
1155            .unwrap(),
1156        );
1157        let schema = Arc::new(
1158            crate::schema::SchemaManager::new(temp_dir.path())
1159                .await
1160                .unwrap(),
1161        );
1162        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
1163
1164        let query_engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
1165
1166        // Test has_schema_for_table with non-existent table
1167        let has_schema = query_engine.has_schema_for_table("nonexistent_table").await;
1168        assert!(!has_schema, "Should return false for non-existent table");
1169
1170        // Test schema_status with non-existent table
1171        let status = query_engine.schema_status("nonexistent_table").await;
1172        match status {
1173            SchemaStatus::Missing { .. } | SchemaStatus::ExtractionFailed { .. } => {
1174                // Expected - either missing or extraction failed is correct
1175            }
1176            SchemaStatus::Available { .. } => {
1177                panic!("Should not be Available for non-existent table");
1178            }
1179        }
1180    }
1181
1182    /// Issue #1587 (E5): the modern (state_machine) ad-hoc SELECT path caches the
1183    /// optimized plan keyed by the exact CQL text, so N identical ad-hoc executes
1184    /// of the same query optimize AT MOST ONCE. This is the `engine.rs` half of
1185    /// the plan-reuse optimization (the prepared-statement half is covered by
1186    /// `prepared_select_reuses_optimized_plan_across_executes`).
1187    ///
1188    /// `#[tokio::test]` runs on the current-thread runtime, so the optimizer's
1189    /// thread-local invocation counter reflects exactly this future's calls. The
1190    /// plan is cached BEFORE execution, so the (empty-store) execute result is
1191    /// irrelevant — reuse holds regardless.
1192    #[tokio::test]
1193    #[cfg(feature = "state_machine")]
1194    async fn adhoc_select_reuses_cached_optimized_plan() {
1195        use crate::query::select_optimizer::OPTIMIZE_INVOCATIONS;
1196
1197        let temp_dir = TempDir::new().unwrap();
1198        let mut config = Config::default();
1199        // Enable the modern SELECT plan cache (0 disables it).
1200        config.query.query_cache_size = Some(16);
1201        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1202        let storage = Arc::new(
1203            crate::storage::StorageEngine::open(
1204                temp_dir.path(),
1205                &config,
1206                platform,
1207                #[cfg(feature = "state_machine")]
1208                None,
1209            )
1210            .await
1211            .unwrap(),
1212        );
1213        let schema = Arc::new(
1214            crate::schema::SchemaManager::new(temp_dir.path())
1215                .await
1216                .unwrap(),
1217        );
1218        let memory = Arc::new(crate::memory::MemoryManager::new(&config).unwrap());
1219        let engine = QueryEngine::new(storage, schema, memory, &config).unwrap();
1220
1221        // Every SELECT routes through `execute_select_query` (issue #1750 retired
1222        // the `WHERE id =` text fork), which owns the modern `select_plan_cache`.
1223        let cql = "SELECT * FROM t WHERE age > 5";
1224
1225        OPTIMIZE_INVOCATIONS.with(|c| c.set(0));
1226        for _ in 0..75 {
1227            let _ = engine.execute(cql).await;
1228        }
1229        let after_same = OPTIMIZE_INVOCATIONS.with(|c| c.get());
1230        assert!(
1231            after_same <= 1,
1232            "issue #1587: an ad-hoc SELECT must optimize at most once across 75 \
1233             identical executes (modern plan cache), got {after_same}"
1234        );
1235
1236        // Clearing the cache must force exactly one re-optimize on the next run.
1237        engine.clear_plan_cache();
1238        let _ = engine.execute(cql).await;
1239        let after_clear = OPTIMIZE_INVOCATIONS.with(|c| c.get());
1240        assert_eq!(
1241            after_clear,
1242            after_same + 1,
1243            "clearing the plan cache must re-optimize once (no stale reuse)"
1244        );
1245    }
1246}
1247
1248// Plan-cache HIT path lock-hygiene coverage (issue #1595, spec Requirement 2:
1249// "Plan-cache hit path uses a shared (read) lock"). Kept in its own file to
1250// avoid growing this already-oversized module; declared here (a descendant of
1251// `engine`) so it can read the private `plan_cache`/`select_plan_cache` fields
1252// and the `pub` `hit_count` atomic directly, with no new public accessor.
1253#[cfg(test)]
1254#[path = "engine_lock_hygiene_tests.rs"]
1255mod engine_lock_hygiene_tests;
1256
1257#[cfg(test)]
1258#[cfg(all(feature = "experimental", feature = "state_machine"))]
1259mod plan_cache_tests {
1260    use super::*;
1261    use crate::{
1262        memory::MemoryManager, platform::Platform, schema::SchemaManager, storage::StorageEngine,
1263        Config,
1264    };
1265    use std::sync::Arc;
1266    use tempfile::TempDir;
1267
1268    async fn setup_query_engine(config: &Config) -> (QueryEngine, TempDir) {
1269        let temp_dir = TempDir::new().unwrap();
1270        let platform = Arc::new(Platform::new(config).await.unwrap());
1271        let storage = Arc::new(
1272            StorageEngine::open(
1273                temp_dir.path(),
1274                config,
1275                platform,
1276                #[cfg(feature = "state_machine")]
1277                None,
1278            )
1279            .await
1280            .unwrap(),
1281        );
1282        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
1283        let memory = Arc::new(MemoryManager::new(config).unwrap());
1284
1285        let engine = QueryEngine::new(storage, schema, memory, config).unwrap();
1286        (engine, temp_dir)
1287    }
1288
1289    /// Number of cached modern SELECT plans. Since issue #1750 ALL SELECTs
1290    /// (including `WHERE id = ?` point lookups) route through the modern
1291    /// `SelectExecutor`, whose plans live in `select_plan_cache`, not the legacy
1292    /// `plan_cache` surfaced by `cache_stats`; these LRU tests assert on it.
1293    fn select_plan_cache_len(engine: &QueryEngine) -> usize {
1294        engine.select_plan_cache.len()
1295    }
1296
1297    /// Register the table schema used by the plan-cache tests. Write path removed
1298    /// in Issue #175, so this only issues `CREATE TABLE` DDL — no row inserts. The
1299    /// assertions depend solely on planning (cache population/eviction), not
1300    /// stored rows, so an empty table exercises current behavior exactly.
1301    async fn create_sample_table(engine: &QueryEngine) {
1302        engine
1303            .execute(
1304                "CREATE TABLE plan_cache_test (
1305                    id INTEGER PRIMARY KEY,
1306                    value TEXT
1307                )",
1308            )
1309            .await
1310            .unwrap();
1311    }
1312
1313    #[tokio::test]
1314    async fn test_plan_cache_disabled() {
1315        let mut config = Config::default();
1316        config.query.query_cache_size = Some(0);
1317
1318        let (engine, _temp_dir) = setup_query_engine(&config).await;
1319        create_sample_table(&engine).await;
1320
1321        engine
1322            .execute("SELECT * FROM plan_cache_test WHERE id = 1")
1323            .await
1324            .unwrap();
1325
1326        assert_eq!(select_plan_cache_len(&engine), 0);
1327    }
1328
1329    #[tokio::test]
1330    async fn test_plan_cache_reuse_point_lookup() {
1331        let mut config = Config::default();
1332        config.query.query_cache_size = Some(4);
1333
1334        let (engine, _temp_dir) = setup_query_engine(&config).await;
1335        create_sample_table(&engine).await;
1336
1337        engine.clear_plan_cache();
1338
1339        engine
1340            .execute("SELECT * FROM plan_cache_test WHERE id = 1")
1341            .await
1342            .unwrap();
1343        engine
1344            .execute("SELECT * FROM plan_cache_test WHERE id = 1")
1345            .await
1346            .unwrap();
1347
1348        assert_eq!(select_plan_cache_len(&engine), 1);
1349        assert!(engine.stats().cache_hit_ratio > 0.0);
1350    }
1351
1352    #[tokio::test]
1353    async fn test_plan_cache_eviction_limit() {
1354        let mut config = Config::default();
1355        config.query.query_cache_size = Some(2);
1356
1357        let (engine, _temp_dir) = setup_query_engine(&config).await;
1358        create_sample_table(&engine).await;
1359
1360        engine.clear_plan_cache();
1361
1362        for id in 1..=3 {
1363            engine
1364                .execute(&format!("SELECT * FROM plan_cache_test WHERE id = {}", id))
1365                .await
1366                .unwrap();
1367        }
1368
1369        assert_eq!(select_plan_cache_len(&engine), 2);
1370    }
1371}