Skip to main content

linkrs_api/api_core/
query_api.rs

1//! Query Execution API – Core Layer
2//!
3//! Provides transport layer independent query execution.
4//!
5//! The original single `query_api.rs` (≈802 lines) mixed the execution core
6//! with ~20 `with_*` builder / configuration methods. The builders now live in
7//! the sibling `query_api_builder` module (declared alongside this file in
8//! `api_core.rs`); this file keeps the type, the constructor, the execution
9//! core, the batch helpers, and the unit tests.
10
11use crate::api_core::error::{CoreError, CoreResult};
12use crate::api_core::types::{ExecutionMetadata, QueryRequest, QueryResult};
13use linkrs_metrics::StatsManager;
14use linkrs_query::executor::base::ExecutionResult;
15use linkrs_query::executor::streaming::StreamingQueryResult;
16use linkrs_query::{OptimizerEngine, QueryPipelineManager};
17use linkrs_storage::{
18    AutoCommitBatchOps, AutoCommitGroupOps, QueryStorage, StorageClient, StorageOperationContext,
19};
20use linkrs_transaction::TransactionExecution;
21use parking_lot::RwLock;
22use std::sync::Arc;
23use std::time::Instant;
24
25/// Universal Query API – Core Layer
26pub struct QueryApi<S: StorageClient + 'static> {
27    pub(crate) pipeline_manager: QueryPipelineManager<S>,
28}
29
30impl<S: StorageClient + Clone + 'static> QueryApi<S> {
31    /// Shared assembly core: every public constructor delegates here so the
32    /// optimizer engine wiring cannot drift between constructors.
33    pub(crate) fn pipeline_with_engine(
34        storage: Arc<RwLock<S>>,
35        stats_manager: Arc<StatsManager>,
36        optimizer_engine: Arc<OptimizerEngine>,
37    ) -> QueryPipelineManager<S> {
38        QueryPipelineManager::with_optimizer(storage, stats_manager, optimizer_engine)
39    }
40
41    /// Create a new QueryApi instance with external StatsManager
42    pub fn new(storage: Arc<RwLock<S>>, stats_manager: Arc<StatsManager>) -> Self {
43        let optimizer_engine = Arc::new(OptimizerEngine::default());
44        Self {
45            pipeline_manager: Self::pipeline_with_engine(storage, stats_manager, optimizer_engine),
46        }
47    }
48
49    /// Execute a query with an explicit transaction execution binding.
50    ///
51    /// The `execution` parameter carries the full transaction identity
52    /// (ID, timestamps, mode, owner) from `TransactionManager`.
53    /// This is the preferred entry point for all transactional DML.
54    pub fn execute_with_execution(
55        &mut self,
56        query: &str,
57        ctx: QueryRequest,
58        execution: &TransactionExecution,
59    ) -> CoreResult<QueryResult> {
60        let op_ctx = StorageOperationContext::transaction_with_timestamps(
61            execution.transaction_id(),
62            execution.read_timestamp(),
63            execution.write_timestamp(),
64            execution.read_only(),
65            execution.auto_commit(),
66        );
67        let op_ctx = execution
68            .mutation_recorder()
69            .map_or(op_ctx.clone(), |recorder| {
70                op_ctx.with_mutation_recorder(recorder)
71            });
72        let mut ctx = ctx;
73        ctx.transaction_id = Some(execution.transaction_id());
74        ctx.auto_commit = execution.auto_commit();
75        ctx.isolation_level = Some(execution.isolation_level());
76        self.execute_with_operation_context_and_storage(query, ctx, Some(op_ctx), None)
77    }
78
79    /// Execute a query with the given query request
80    ///
81    /// # Parameters
82    /// `query`: The query statement
83    /// - `ctx`: query request
84    ///
85    /// # Return
86    /// Structured Search Results
87    pub fn execute(&mut self, query: &str, ctx: QueryRequest) -> CoreResult<QueryResult> {
88        self.execute_with_operation_context_and_storage(query, ctx, None, None)
89    }
90
91    pub fn execute_with_operation_storage(
92        &mut self,
93        query: &str,
94        ctx: QueryRequest,
95        storage: S,
96    ) -> CoreResult<QueryResult> {
97        let operation_context = storage.operation_context().as_deref().cloned();
98        self.execute_with_operation_context_and_storage(
99            query,
100            ctx,
101            operation_context,
102            Some(storage),
103        )
104    }
105
106    fn execute_with_operation_context_and_storage(
107        &mut self,
108        query: &str,
109        ctx: QueryRequest,
110        operation_context: Option<StorageOperationContext>,
111        operation_storage: Option<S>,
112    ) -> CoreResult<QueryResult> {
113        let start_time = Instant::now();
114        let operation_finalizer = operation_storage.clone();
115
116        // Constructing a QueryRequestContext
117        let mut request_context = linkrs_query::QueryRequestContext::new(query.to_string());
118        request_context.query_id = ctx.query_id;
119        request_context.transaction_id = ctx.transaction_id.or_else(|| {
120            operation_context
121                .as_ref()
122                .and_then(|context| context.transaction_id)
123        });
124        request_context.auto_commit = ctx.auto_commit;
125        request_context.parameters = ctx.parameters.clone().unwrap_or_default();
126        request_context.session_variables = ctx.session_variables.clone().unwrap_or_default();
127        request_context.parsed_statement = ctx.parsed_statement.clone();
128        request_context.read_only = operation_context
129            .as_ref()
130            .is_some_and(|context| context.read_only);
131        request_context.isolation_level = ctx.isolation_level;
132        request_context.ryw_config = ctx.consistency.read_your_writes().copied();
133        request_context.operation_context = operation_context;
134        request_context.operation_storage = operation_storage
135            .map(|storage| Arc::new(RwLock::new(storage)) as Arc<RwLock<dyn QueryStorage>>);
136        let rctx = Arc::new(request_context);
137
138        // Build space info from request context if space_id is provided
139        let space_info = ctx.space_id.map(|id| {
140            let space_name = ctx.space_name.clone().unwrap_or_default();
141            let mut space_info = linkrs_core::types::SpaceInfo::new(space_name);
142            space_info.space_id = id;
143            space_info
144        });
145
146        // Execute the query (using the new execute_query_with_request method).
147        let execution_result = match self.pipeline_manager.execute_query_with_request_scope(
148            query,
149            rctx,
150            space_info,
151            ctx.transaction_id,
152        ) {
153            Ok(result) => result,
154            Err(error) => {
155                if ctx.auto_commit {
156                    if let Some(storage) = operation_finalizer.as_ref() {
157                        storage
158                            .finalize_operation(false)
159                            .map_err(|cleanup| CoreError::StorageError(cleanup.to_string()))?;
160                    }
161                }
162                return Err(CoreError::from(error));
163            }
164        };
165
166        if ctx.auto_commit {
167            if let Some(storage) = operation_finalizer.as_ref() {
168                storage
169                    .finalize_operation(true)
170                    .map_err(|error| CoreError::StorageError(error.to_string()))?;
171            }
172        }
173
174        // Wrap the engine execution result with API-layer metadata.
175        let mut result = Self::attach_metadata(execution_result)?;
176        result.metadata.execution_time_ms = start_time.elapsed().as_millis() as u64;
177
178        Ok(result)
179    }
180
181    /// Execute a query with an explicit transaction execution binding (streaming).
182    pub fn execute_stream_with_execution(
183        &mut self,
184        query: &str,
185        ctx: QueryRequest,
186        execution: &TransactionExecution,
187    ) -> CoreResult<StreamingQueryResult> {
188        let op_ctx = StorageOperationContext::transaction_with_timestamps(
189            execution.transaction_id(),
190            execution.read_timestamp(),
191            execution.write_timestamp(),
192            execution.read_only(),
193            execution.auto_commit(),
194        );
195        let op_ctx = execution
196            .mutation_recorder()
197            .map_or(op_ctx.clone(), |recorder| {
198                op_ctx.with_mutation_recorder(recorder)
199            });
200        let mut ctx = ctx;
201        ctx.transaction_id = Some(execution.transaction_id());
202        ctx.auto_commit = execution.auto_commit();
203        ctx.isolation_level = Some(execution.isolation_level());
204        self.execute_stream_with_operation_context_and_storage(query, ctx, Some(op_ctx), None)
205    }
206
207    /// Execute a query and return a [`StreamingQueryResult`] for chunk-at-a-time consumption.
208    ///
209    /// Unlike [`execute`] which materialises the full result set, this method
210    /// returns a thread-safe streaming handle that lets the caller pull chunks
211    /// one at a time.  Useful for SSE / gRPC streaming endpoints.
212    pub fn execute_stream(
213        &mut self,
214        query: &str,
215        ctx: QueryRequest,
216    ) -> CoreResult<StreamingQueryResult> {
217        self.execute_stream_with_operation_context_and_storage(query, ctx, None, None)
218    }
219
220    pub fn execute_stream_with_operation_storage(
221        &mut self,
222        query: &str,
223        ctx: QueryRequest,
224        storage: S,
225    ) -> CoreResult<StreamingQueryResult> {
226        let operation_context = storage.operation_context().as_deref().cloned();
227        self.execute_stream_with_operation_context_and_storage(
228            query,
229            ctx,
230            operation_context,
231            Some(storage),
232        )
233    }
234
235    fn execute_stream_with_operation_context_and_storage(
236        &mut self,
237        query: &str,
238        ctx: QueryRequest,
239        operation_context: Option<StorageOperationContext>,
240        operation_storage: Option<S>,
241    ) -> CoreResult<StreamingQueryResult> {
242        let operation_owned = operation_storage.is_some() && ctx.auto_commit;
243        let mut request_context = linkrs_query::QueryRequestContext::new(query.to_string());
244        request_context.query_id = ctx.query_id;
245        request_context.transaction_id = ctx.transaction_id.or_else(|| {
246            operation_context
247                .as_ref()
248                .and_then(|context| context.transaction_id)
249        });
250        request_context.auto_commit = ctx.auto_commit;
251        request_context.parameters = ctx.parameters.clone().unwrap_or_default();
252        request_context.session_variables = ctx.session_variables.clone().unwrap_or_default();
253        request_context.parsed_statement = ctx.parsed_statement.clone();
254        request_context.read_only = operation_context
255            .as_ref()
256            .is_some_and(|context| context.read_only);
257        request_context.isolation_level = ctx.isolation_level;
258        request_context.ryw_config = ctx.consistency.read_your_writes().copied();
259        request_context.operation_context = operation_context;
260        request_context.operation_storage = operation_storage
261            .map(|storage| Arc::new(RwLock::new(storage)) as Arc<RwLock<dyn QueryStorage>>);
262        let rctx = Arc::new(request_context);
263
264        let space_info = ctx.space_id.map(|id| {
265            let space_name = ctx.space_name.clone().unwrap_or_default();
266            let mut space_info = linkrs_core::types::SpaceInfo::new(space_name);
267            space_info.space_id = id;
268            space_info
269        });
270
271        let result = self
272            .pipeline_manager
273            .execute_query_stream_with_request_scope(query, rctx, space_info, ctx.transaction_id)
274            .map_err(CoreError::from)?;
275
276        if operation_owned {
277            if let Some(storage) = result.runtime().storage.clone() {
278                let commit_storage = storage.clone();
279                let abort_storage = storage;
280                result.set_transaction_finalizer_with_result(
281                    Box::new(move || {
282                        commit_storage
283                            .write()
284                            .finalize_operation(true)
285                            .map_err(|error| error.to_string())
286                    }),
287                    Box::new(move || {
288                        abort_storage
289                            .write()
290                            .finalize_operation(false)
291                            .map_err(|error| error.to_string())
292                    }),
293                );
294            }
295        }
296        Ok(result)
297    }
298
299    /// Attach API-layer metadata to an engine execution result.
300    ///
301    /// The engine result is carried through unchanged (no row conversion):
302    /// the API core layer adds only timing / count metadata. `ExecutionResult::Error`
303    /// is surfaced as an internal error, all other variants pass through.
304    /// Scanned rows stay zero here because the true scan volume is owned by
305    /// executor statistics, not by the returned row count.
306    fn attach_metadata(execution: ExecutionResult) -> CoreResult<QueryResult> {
307        let rows_returned = match &execution {
308            ExecutionResult::DataSet { data, .. } => data.row_count(),
309            _ => 0,
310        };
311        match execution {
312            ExecutionResult::Error(msg) => Err(CoreError::Internal(msg)),
313            other => Ok(QueryResult::new(
314                other,
315                ExecutionMetadata {
316                    execution_time_ms: 0,
317                    rows_scanned: 0,
318                    rows_returned: rows_returned as u64,
319                    cache_hit: false,
320                },
321            )),
322        }
323    }
324}
325
326impl<S> QueryApi<S>
327where
328    S: StorageClient + Clone + AutoCommitBatchOps + 'static,
329{
330    pub fn execute_batch(
331        &mut self,
332        queries: &[String],
333        ctx: QueryRequest,
334    ) -> Vec<Result<QueryResult, CoreError>> {
335        let mut results = Vec::with_capacity(queries.len());
336        let base_storage = match self.pipeline_manager.storage() {
337            Some(storage) => storage,
338            None => {
339                for _ in queries {
340                    results.push(Err(CoreError::Internal("No storage binding".to_string())));
341                }
342                return results;
343            }
344        };
345
346        let window = match base_storage.read().begin_auto_commit_batch() {
347            Ok(window) => window,
348            Err(error) => {
349                for _ in queries {
350                    results.push(Err(CoreError::StorageError(error.to_string())));
351                }
352                return results;
353            }
354        };
355
356        for query in queries {
357            let stmt_storage = match base_storage.read().bind_auto_commit_statement(&window) {
358                Ok(storage) => storage,
359                Err(error) => {
360                    results.push(Err(CoreError::StorageError(error.to_string())));
361                    continue;
362                }
363            };
364            let mut batch_ctx = ctx.clone();
365            batch_ctx.auto_commit = true;
366            batch_ctx.transaction_id = None;
367            results.push(self.execute_with_operation_storage(query, batch_ctx, stmt_storage));
368        }
369
370        if let Err(error) = base_storage.read().finalize_auto_commit_batch(&window) {
371            results.push(Err(CoreError::StorageError(error.to_string())));
372        }
373        results
374    }
375
376    pub fn dml_plan_memo_hits(&self) -> u64 {
377        self.pipeline_manager
378            .last_dml_plan_hits
379            .load(std::sync::atomic::Ordering::Relaxed)
380    }
381
382    pub fn dml_template_ast_parse_count(&self) -> u64 {
383        self.pipeline_manager.dml_template_ast_parse_count()
384    }
385
386    pub fn dml_template_ast_hit_count(&self) -> u64 {
387        self.pipeline_manager.dml_template_ast_hit_count()
388    }
389}
390
391impl<S> QueryApi<S>
392where
393    S: StorageClient + Clone + AutoCommitBatchOps + AutoCommitGroupOps + 'static,
394{
395    /// Execute a batch of auto-commit DML statements in group-commit windows.
396    /// `group_size` is clamped to >= 1; each consecutive group of statements
397    /// shares one write timestamp, one WAL fsync, and one commit point.
398    /// Statement results are returned per statement, in order, exactly like
399    /// `execute_batch`; failed statements roll back only their own writes.
400    /// DDL / transaction statements / USE must not be mixed in (same contract
401    /// as `execute_batch`).
402    pub fn execute_batch_grouped(
403        &mut self,
404        queries: &[String],
405        ctx: QueryRequest,
406        group_size: usize,
407    ) -> Vec<Result<QueryResult, CoreError>> {
408        let group_size = group_size.max(1);
409        let mut results = Vec::with_capacity(queries.len());
410        let base_storage = match self.pipeline_manager.storage() {
411            Some(storage) => storage,
412            None => {
413                for _ in queries {
414                    results.push(Err(CoreError::Internal("No storage binding".to_string())));
415                }
416                return results;
417            }
418        };
419
420        for chunk in queries.chunks(group_size) {
421            let window = match base_storage.read().begin_auto_commit_group() {
422                Ok(window) => window,
423                Err(error) => {
424                    for _ in chunk {
425                        results.push(Err(CoreError::StorageError(error.to_string())));
426                    }
427                    continue;
428                }
429            };
430
431            for query in chunk {
432                let stmt_storage = match base_storage.read().bind_auto_commit_statement(&window) {
433                    Ok(storage) => storage,
434                    Err(error) => {
435                        results.push(Err(CoreError::StorageError(error.to_string())));
436                        continue;
437                    }
438                };
439                let mut batch_ctx = ctx.clone();
440                batch_ctx.auto_commit = true;
441                batch_ctx.transaction_id = None;
442                results.push(self.execute_with_operation_storage(query, batch_ctx, stmt_storage));
443            }
444
445            if let Err(error) = base_storage.read().finalize_auto_commit_group(&window) {
446                results.push(Err(CoreError::StorageError(error.to_string())));
447            }
448        }
449        results
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use linkrs_storage::MockStorage;
457
458    fn create_mock_storage() -> Arc<RwLock<MockStorage>> {
459        Arc::new(RwLock::new(
460            MockStorage::new().expect("Failed to create MockStorage"),
461        ))
462    }
463
464    #[test]
465    fn statistics_settings_default_to_pipeline_fallbacks() {
466        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()));
467        assert_eq!(
468            api.statistics_sample_limit(),
469            linkrs_query::QueryPipelineManager::<MockStorage>::DEFAULT_STATISTICS_SAMPLE_LIMIT
470        );
471        assert_eq!(
472            api.statistics_min_epoch_delta(),
473            linkrs_query::QueryPipelineManager::<MockStorage>::DEFAULT_STATISTICS_MIN_EPOCH_DELTA
474        );
475    }
476
477    #[test]
478    fn statistics_settings_override_reaches_pipeline() {
479        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()))
480            .with_statistics_settings(500, 10);
481        assert_eq!(api.statistics_sample_limit(), 500);
482        assert_eq!(api.statistics_min_epoch_delta(), 10);
483    }
484
485    #[test]
486    fn statistics_sample_limit_clamps_to_one() {
487        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()))
488            .with_statistics_settings(0, 0);
489        assert_eq!(api.statistics_sample_limit(), 1);
490        assert_eq!(api.statistics_min_epoch_delta(), 0);
491    }
492
493    #[test]
494    fn cost_config_defaults_to_engine_default() {
495        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()));
496        assert_eq!(
497            api.cost_config().random_page_cost,
498            linkrs_query::optimizer::CostModelConfig::default().random_page_cost
499        );
500    }
501
502    #[test]
503    fn cost_profile_memory_runtime_reaches_engine() {
504        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()))
505            .with_cost_profile(StorageCostProfile::Auto, &RuntimeConfig::memory());
506        assert_eq!(
507            api.cost_config().random_page_cost,
508            linkrs_query::optimizer::CostModelConfig::for_in_memory().random_page_cost
509        );
510    }
511
512    #[test]
513    fn space_overrides_reach_engine_calculators() {
514        use std::collections::HashMap;
515
516        let mut profiles = HashMap::new();
517        profiles.insert("cold".into(), StorageCostProfile::Hdd);
518        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()))
519            .with_cost_profile(StorageCostProfile::Ssd, &RuntimeConfig::memory())
520            .with_space_cost_profiles(&profiles, &RuntimeConfig::memory());
521        // Memory runtime short-circuits the Hdd override to in-memory.
522        assert_eq!(
523            api.cost_config().random_page_cost,
524            linkrs_query::optimizer::CostModelConfig::for_in_memory().random_page_cost
525        );
526        let engine = api.pipeline_manager.optimizer_engine();
527        assert_eq!(
528            engine
529                .cost_calculator_for(Some("cold"))
530                .config()
531                .random_page_cost,
532            linkrs_query::optimizer::CostModelConfig::for_in_memory().random_page_cost
533        );
534    }
535
536    #[test]
537    fn cost_config_rebuild_preserves_space_overrides() {
538        use std::collections::HashMap;
539
540        let mut profiles = HashMap::new();
541        profiles.insert("cold".into(), StorageCostProfile::Hdd);
542        let runtime = RuntimeConfig::file("/tmp/linkrs-test-data");
543        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()))
544            .with_space_cost_profiles(&profiles, &runtime)
545            .with_cost_config(linkrs_query::optimizer::CostModelConfig::for_ssd());
546        let engine = api.pipeline_manager.optimizer_engine();
547        // Undetectable dir falls back to Ssd; the Hdd space override survives
548        // the engine rebuild and still prices random pages at 4.0.
549        assert_eq!(
550            engine
551                .cost_calculator_for(Some("cold"))
552                .config()
553                .random_page_cost,
554            linkrs_query::optimizer::CostModelConfig::for_hdd().random_page_cost
555        );
556        assert_eq!(
557            engine
558                .cost_calculator_for(Some("hot"))
559                .config()
560                .random_page_cost,
561            linkrs_query::optimizer::CostModelConfig::for_ssd().random_page_cost
562        );
563    }
564    #[test]
565    fn memory_runtime_short_circuits_explicit_preset() {
566        let api = QueryApi::new(create_mock_storage(), Arc::new(StatsManager::new()))
567            .with_cost_profile(StorageCostProfile::Hdd, &RuntimeConfig::memory());
568        assert_eq!(
569            api.cost_config().random_page_cost,
570            linkrs_query::optimizer::CostModelConfig::for_in_memory().random_page_cost
571        );
572    }
573}