1use std::collections::{HashMap, HashSet, VecDeque};
34use std::sync::{Arc, Mutex, RwLock};
35
36use arrow_array::RecordBatch;
37use arrow_schema::SchemaRef;
38use datafusion::datasource::{DefaultTableSource, MemTable, TableProvider};
39use datafusion::execution::context::{SessionConfig, SessionContext};
40use datafusion::execution::session_state::SessionStateBuilder;
41use datafusion::execution::SendableRecordBatchStream;
42use futures::StreamExt;
43use lance::datafusion::LanceTableProvider;
44use lance::dataset::builder::DatasetBuilder;
45use lance::dataset::Dataset;
46use serde_json::Value as JsonValue;
47
48use crate::config::GraphConfig;
49use crate::csr_expand::{
50 edge_changes, is_supported_id_type, Adjacency, AdjacencyCatalog, CsrQueryPlanner, NodeRows,
51 PruneCsrTargetColumns, PushCsrTargetFilters,
52};
53use crate::error::{GraphError, Result};
54use crate::key_statistics::KeyStatisticsProvider;
55use crate::lance_vector_search::VectorSearch;
56use crate::logical_plan::FanOut;
57use crate::query::CypherQuery;
58use crate::{GraphSourceCatalog, InMemoryCatalog};
59
60#[derive(Clone)]
62pub enum EngineSource {
63 Lance {
69 uri: String,
70 storage_options: HashMap<String, String>,
71 dataset: Arc<Dataset>,
72 },
73 Arrow(RecordBatch),
76}
77
78impl EngineSource {
79 pub async fn open_lance(
84 uri: impl Into<String>,
85 version: Option<u64>,
86 storage_options: HashMap<String, String>,
87 ) -> Result<Self> {
88 let uri = uri.into();
89 let dataset = open_lance_dataset(&uri, version, &storage_options).await?;
90 Ok(Self::Lance {
91 uri,
92 storage_options,
93 dataset: Arc::new(dataset),
94 })
95 }
96
97 pub fn lance_version(&self) -> Option<u64> {
99 match self {
100 Self::Lance { dataset, .. } => Some(dataset.manifest().version),
101 Self::Arrow(_) => None,
102 }
103 }
104
105 fn same_data(&self, other: &Self) -> bool {
108 match (self, other) {
109 (Self::Lance { dataset: a, .. }, Self::Lance { dataset: b, .. }) => Arc::ptr_eq(a, b),
110 (Self::Arrow(a), Self::Arrow(b)) => {
111 a.num_columns() == b.num_columns()
112 && a.columns()
113 .iter()
114 .zip(b.columns())
115 .all(|(x, y)| x.to_data().ptr_eq(&y.to_data()))
116 }
117 _ => false,
118 }
119 }
120
121 async fn num_rows(&self, name: &str) -> Result<usize> {
123 Ok(match self {
124 Self::Arrow(batch) => batch.num_rows(),
125 Self::Lance { dataset, .. } => dataset
126 .count_rows(None)
127 .await
128 .map_err(|e| exec_error(format!("Failed to count rows of {}: {}", name, e)))?,
129 })
130 }
131}
132
133#[derive(Debug, Clone, PartialEq)]
135pub struct CsrStats {
136 pub edges: u64,
138 pub delta_edges: u64,
140 pub tombstones: u64,
142 pub update: &'static str,
145 pub version: Option<u64>,
147 pub build_ms: f64,
149}
150
151#[derive(Debug, Clone, PartialEq)]
153pub struct NodeIndexStats {
154 pub rows: usize,
156 pub version: Option<u64>,
158 pub build_ms: f64,
160}
161
162struct EngineState {
167 sources: HashMap<String, EngineSource>,
168 plans: Mutex<VecDeque<CachedQuery>>,
170 catalog: Arc<dyn GraphSourceCatalog>,
171 context: SessionContext,
172 csr: HashMap<String, CsrEntry>,
174 node_index: HashMap<String, NodeIndexEntry>,
176 key_stats: HashMap<String, KeyStatsEntry>,
178}
179
180const PLAN_CACHE_ENTRIES: usize = 128;
182
183struct CachedQuery {
185 text: String,
186 parameter_types: Vec<(String, u8)>,
187 query: CypherQuery,
188}
189
190fn parameter_types(parameters: &HashMap<String, JsonValue>) -> Option<Vec<(String, u8)>> {
192 let mut types = parameters
193 .iter()
194 .map(|(name, value)| {
195 let kind = match value {
196 JsonValue::Null => 0,
197 JsonValue::Bool(_) => 1,
198 JsonValue::String(_) => 2,
199 JsonValue::Number(value) if value.is_i64() => 3,
200 JsonValue::Number(value) if value.is_f64() => 4,
201 _ => return None,
202 };
203 Some((name.clone(), kind))
204 })
205 .collect::<Option<Vec<_>>>()?;
206 types.sort_unstable();
207 Some(types)
208}
209
210#[derive(Clone)]
212struct KeyStatsEntry {
213 source: EngineSource,
215 distinct: HashMap<String, usize>,
217 fan_out: Option<(String, f64, f64)>,
219}
220
221#[derive(Clone)]
223struct NodeIndexEntry {
224 source: EngineSource,
225 rows: Arc<NodeRows>,
226 build_ms: f64,
227}
228
229#[derive(Clone)]
231struct CsrEntry {
232 source: EngineSource,
234 outgoing: Arc<Adjacency>,
235 incoming: Arc<Adjacency>,
236 build_ms: f64,
238 update: &'static str,
240}
241
242const CSR_REBUILD_FRACTION: f64 = 0.25;
251
252pub const MAX_TARGET_PARTITIONS: usize = 1024;
255
256async fn try_apply_csr_delta(
261 old: &CsrEntry,
262 source: &EngineSource,
263 src: &str,
264 dst: &str,
265) -> Option<CsrEntry> {
266 let (
267 EngineSource::Lance {
268 dataset: old_ds, ..
269 },
270 EngineSource::Lance {
271 dataset: new_ds, ..
272 },
273 ) = (&old.source, source)
274 else {
275 return None;
276 };
277 let started = std::time::Instant::now();
278 let Ok(Some(changes)) = edge_changes(old_ds, new_ds, src, dst).await else {
279 return None;
280 };
281 let elapsed = || started.elapsed().as_secs_f64() * 1e3;
282 if changes.is_empty() {
283 return Some(CsrEntry {
284 source: source.clone(),
285 build_ms: elapsed(),
286 update: "reused",
287 ..old.clone()
288 });
289 }
290 let base = old.outgoing.base_edges() as f64;
291 let pending = old.outgoing.delta_edges() + old.outgoing.tombstones() + changes.len() as u64;
292 if pending as f64 > CSR_REBUILD_FRACTION * base {
293 return None;
294 }
295 let flip = |edges: &[(i64, i64)]| edges.iter().map(|&(a, b)| (b, a)).collect::<Vec<_>>();
296 let outgoing = old.outgoing.with_changes(&changes.added, &changes.removed);
297 let incoming = old
298 .incoming
299 .with_changes(&flip(&changes.added), &flip(&changes.removed));
300 Some(CsrEntry {
301 source: source.clone(),
302 outgoing: Arc::new(outgoing),
303 incoming: Arc::new(incoming),
304 build_ms: elapsed(),
305 update: "delta",
306 })
307}
308
309impl EngineState {
310 fn fan_out(&self) -> FanOut {
312 self.key_stats
313 .values()
314 .filter_map(|entry| entry.fan_out.as_ref())
315 .flat_map(|(rel_type, outgoing, incoming)| {
316 [
317 ((rel_type.clone(), true), *outgoing),
318 ((rel_type.clone(), false), *incoming),
319 ]
320 })
321 .collect()
322 }
323
324 fn adjacency_catalog(&self) -> AdjacencyCatalog {
325 let mut catalog = AdjacencyCatalog::default();
326 for (rel_type, entry) in &self.csr {
327 catalog.insert(rel_type, true, entry.outgoing.clone());
328 catalog.insert(rel_type, false, entry.incoming.clone());
329 }
330 for (label, entry) in &self.node_index {
331 catalog.insert_nodes(label, entry.rows.clone());
332 }
333 catalog
334 }
335}
336
337fn exec_error(message: String) -> GraphError {
338 GraphError::ExecutionError {
339 message,
340 location: snafu::Location::new(file!(), line!(), column!()),
341 }
342}
343
344fn config_error(message: String) -> GraphError {
345 GraphError::ConfigError {
346 message,
347 location: snafu::Location::new(file!(), line!(), column!()),
348 }
349}
350
351async fn open_lance_dataset(
353 uri: &str,
354 version: Option<u64>,
355 storage_options: &HashMap<String, String>,
356) -> Result<Dataset> {
357 let mut builder = DatasetBuilder::from_uri(uri).with_storage_options(storage_options.clone());
358 if let Some(version) = version {
359 builder = builder.with_version(version);
360 }
361 builder
362 .load()
363 .await
364 .map_err(|e| exec_error(format!("Failed to open Lance dataset {}: {}", uri, e)))
365}
366
367fn same_lance_snapshot(a: &Dataset, b: &Dataset) -> bool {
372 a.manifest().version == b.manifest().version
373 && a.manifest().timestamp_nanos == b.manifest().timestamp_nanos
374}
375
376fn validate_sources(config: &GraphConfig, schemas: &HashMap<String, SchemaRef>) -> Result<()> {
381 let schema_for = |label: &str| {
382 schemas
383 .iter()
384 .find(|(name, _)| name.eq_ignore_ascii_case(label))
385 .map(|(_, schema)| schema)
386 };
387 let check = |label: &str, column: &str| -> Result<()> {
388 if let Some(schema) = schema_for(label) {
389 if !schema
390 .fields()
391 .iter()
392 .any(|f| f.name().eq_ignore_ascii_case(column))
393 {
394 return Err(config_error(format!(
395 "Dataset for '{}' has no column '{}' required by the graph configuration",
396 label, column
397 )));
398 }
399 }
400 Ok(())
401 };
402 for (_, mapping) in config.iter_node_mappings() {
403 check(&mapping.label, &mapping.id_field)?;
404 }
405 for (_, mapping) in config.iter_relationship_mappings() {
406 check(&mapping.relationship_type, &mapping.source_id_field)?;
407 check(&mapping.relationship_type, &mapping.target_id_field)?;
408 }
409 Ok(())
410}
411
412async fn build_engine_state(
414 config: &GraphConfig,
415 sources: HashMap<String, EngineSource>,
416 use_csr: bool,
417 target_partitions: Option<usize>,
418 previous: Option<&EngineState>,
419) -> Result<EngineState> {
420 let mut session_config = SessionConfig::new();
421 if let Some(partitions) = target_partitions {
422 session_config = session_config.with_target_partitions(partitions);
423 }
424 let ctx = SessionContext::new_with_state(
426 SessionStateBuilder::new()
427 .with_default_features()
428 .with_config(session_config)
429 .with_query_planner(Arc::new(CsrQueryPlanner))
430 .with_optimizer_rule(Arc::new(PushCsrTargetFilters))
433 .with_optimizer_rule(Arc::new(PruneCsrTargetColumns))
434 .build(),
435 );
436 let mut schemas = HashMap::new();
437 let mut providers: HashMap<String, Arc<dyn TableProvider>> = HashMap::new();
438
439 for (name, source) in &sources {
440 let provider: Arc<dyn TableProvider> = match source {
441 EngineSource::Lance { dataset, .. } => {
443 Arc::new(LanceTableProvider::new(dataset.clone(), false, false))
444 }
445 EngineSource::Arrow(batch) => Arc::new(
446 MemTable::try_new(batch.schema(), memtable_partitions(batch)).map_err(|e| {
447 exec_error(format!("Failed to create MemTable for {}: {}", name, e))
448 })?,
449 ),
450 };
451 schemas.insert(name.clone(), provider.schema());
452 ctx.register_table(name.to_lowercase(), provider.clone())
454 .map_err(|e| exec_error(format!("Failed to register table {}: {}", name, e)))?;
455 providers.insert(name.clone(), provider);
456 }
457
458 validate_sources(config, &schemas)?;
459 let (csr, node_index) = if use_csr {
460 (
461 build_csr_entries(config, &sources, &schemas, &ctx, previous).await?,
462 build_node_index(config, &sources, previous).await?,
463 )
464 } else {
465 (HashMap::new(), HashMap::new())
466 };
467 let key_stats = build_key_statistics(config, &sources, &schemas, &ctx, &csr, previous).await?;
468
469 let mut catalog = InMemoryCatalog::new();
470 for (name, provider) in providers {
471 let provider: Arc<dyn TableProvider> = match key_stats.get(&name) {
473 Some(entry) => Arc::new(KeyStatisticsProvider::new(provider, entry.distinct.clone())),
474 None => provider,
475 };
476 let table = name.to_lowercase();
477 ctx.deregister_table(table.as_str())
478 .and_then(|_| ctx.register_table(table.as_str(), provider.clone()))
479 .map_err(|e| exec_error(format!("Failed to register table {}: {}", name, e)))?;
480
481 let table_source = Arc::new(DefaultTableSource::new(provider));
484 catalog = catalog
485 .with_node_source(&name, table_source.clone())
486 .with_relationship_source(&name, table_source);
487 }
488 Ok(EngineState {
489 sources,
490 plans: Mutex::default(),
491 catalog: Arc::new(catalog),
492 context: ctx,
493 csr,
494 node_index,
495 key_stats,
496 })
497}
498
499async fn build_node_index(
505 config: &GraphConfig,
506 sources: &HashMap<String, EngineSource>,
507 previous: Option<&EngineState>,
508) -> Result<HashMap<String, NodeIndexEntry>> {
509 let mut entries = HashMap::new();
510 for (_, mapping) in config.iter_node_mappings() {
511 let label = &mapping.label;
512 let Some(source) = sources
513 .iter()
514 .find(|(name, _)| name.eq_ignore_ascii_case(label))
515 .map(|(_, source)| source)
516 else {
517 continue;
518 };
519 if let Some(old) = previous.and_then(|p| p.node_index.get(label)) {
520 if old.source.same_data(source) {
521 entries.insert(label.clone(), old.clone());
522 continue;
523 }
524 }
525 let started = std::time::Instant::now();
526 let rows = match source {
527 EngineSource::Lance { dataset, .. } => {
528 NodeRows::from_lance(dataset.clone(), &mapping.id_field).await?
529 }
530 EngineSource::Arrow(batch) => NodeRows::from_batch(batch.clone(), &mapping.id_field)?,
531 };
532 if let Some(rows) = rows {
533 entries.insert(
534 label.clone(),
535 NodeIndexEntry {
536 source: source.clone(),
537 rows: Arc::new(rows),
538 build_ms: started.elapsed().as_secs_f64() * 1e3,
539 },
540 );
541 }
542 }
543 Ok(entries)
544}
545
546const MEMTABLE_BATCH_ROWS: usize = 8192;
548
549fn memtable_partitions(batch: &RecordBatch) -> Vec<Vec<RecordBatch>> {
556 let partitions = std::thread::available_parallelism()
557 .map(|n| n.get())
558 .unwrap_or(1);
559 let mut out = vec![Vec::new(); partitions];
560 let rows = batch.num_rows();
561 if rows == 0 {
562 return vec![vec![batch.clone()]];
563 }
564 for (i, offset) in (0..rows).step_by(MEMTABLE_BATCH_ROWS).enumerate() {
565 let len = MEMTABLE_BATCH_ROWS.min(rows - offset);
566 out[i % partitions].push(batch.slice(offset, len));
567 }
568 out.retain(|p| !p.is_empty());
569 out
570}
571
572async fn build_key_statistics(
581 config: &GraphConfig,
582 sources: &HashMap<String, EngineSource>,
583 schemas: &HashMap<String, SchemaRef>,
584 ctx: &SessionContext,
585 csr: &HashMap<String, CsrEntry>,
586 previous: Option<&EngineState>,
587) -> Result<HashMap<String, KeyStatsEntry>> {
588 let mut entries = HashMap::new();
589 for (name, source) in sources {
590 if let Some(old) = previous.and_then(|p| p.key_stats.get(name)) {
591 if old.source.same_data(source) {
592 entries.insert(name.clone(), old.clone());
593 continue;
594 }
595 }
596 let column = |wanted: &str| {
597 schemas[name]
598 .fields()
599 .iter()
600 .find(|f| f.name().eq_ignore_ascii_case(wanted))
601 .cloned()
602 };
603 let mut distinct = HashMap::new();
604 let mut to_count = Vec::new();
605 let mut endpoints = None;
607 for (_, mapping) in config.iter_node_mappings() {
608 if !mapping.label.eq_ignore_ascii_case(name) {
609 continue;
610 }
611 if let Some(id) = column(&mapping.id_field) {
612 distinct.insert(id.name().clone(), source.num_rows(name).await?);
613 }
614 }
615 for (_, mapping) in config.iter_relationship_mappings() {
616 if !mapping.relationship_type.eq_ignore_ascii_case(name) {
617 continue;
618 }
619 let (Some(src), Some(dst)) = (
620 column(&mapping.source_id_field),
621 column(&mapping.target_id_field),
622 ) else {
623 continue;
624 };
625 endpoints = Some((
626 mapping.relationship_type.to_lowercase(),
627 src.name().clone(),
628 dst.name().clone(),
629 ));
630 match csr.get(&mapping.relationship_type) {
631 Some(entry) => {
633 distinct.insert(src.name().clone(), entry.outgoing.num_sources());
634 distinct.insert(dst.name().clone(), entry.incoming.num_sources());
635 }
636 None => to_count.extend(
637 [src, dst]
638 .into_iter()
639 .filter(|f| is_supported_id_type(f.data_type()))
640 .map(|f| f.name().clone()),
641 ),
642 }
643 }
644 to_count.retain(|c| !distinct.contains_key(c));
645 if !to_count.is_empty() {
646 let table = name.to_lowercase();
647 let counts = async {
648 let df = ctx.table(table.as_str()).await?;
649 let names: Vec<&str> = to_count.iter().map(String::as_str).collect();
650 let mut stream = df.select_columns(&names)?.execute_stream().await?;
651 let mut seen: Vec<HashSet<i64>> = vec![Default::default(); names.len()];
652 while let Some(batch) = stream.next().await {
653 let batch = batch?;
654 for (i, values) in seen.iter_mut().enumerate() {
655 let column =
656 arrow::compute::cast(batch.column(i), &arrow_schema::DataType::Int64)?;
657 let column = column
658 .as_any()
659 .downcast_ref::<arrow_array::Int64Array>()
660 .expect("cast to Int64");
661 values.extend(column.iter().flatten());
662 }
663 }
664 Ok::<_, datafusion::error::DataFusionError>(
665 seen.into_iter().map(|s| s.len()).collect::<Vec<_>>(),
666 )
667 }
668 .await
669 .map_err(|e| exec_error(format!("Failed to count join keys of {}: {}", name, e)))?;
670 distinct.extend(to_count.into_iter().zip(counts));
671 }
672 let fan_out = match endpoints {
673 Some((rel_type, src, dst)) if distinct.contains_key(&src) => {
674 let edges = source.num_rows(name).await? as f64;
675 let per = |c: &str| edges / distinct.get(c).copied().unwrap_or(1).max(1) as f64;
676 Some((rel_type, per(&src), per(&dst)))
677 }
678 _ => None,
679 };
680 if !distinct.is_empty() {
681 entries.insert(
682 name.clone(),
683 KeyStatsEntry {
684 source: source.clone(),
685 distinct,
686 fan_out,
687 },
688 );
689 }
690 }
691 Ok(entries)
692}
693
694async fn build_csr_entries(
701 config: &GraphConfig,
702 sources: &HashMap<String, EngineSource>,
703 schemas: &HashMap<String, SchemaRef>,
704 ctx: &SessionContext,
705 previous: Option<&EngineState>,
706) -> Result<HashMap<String, CsrEntry>> {
707 let mut entries = HashMap::new();
708 for (_, mapping) in config.iter_relationship_mappings() {
709 let rel_type = &mapping.relationship_type;
710 let Some((name, source)) = sources
711 .iter()
712 .find(|(name, _)| name.eq_ignore_ascii_case(rel_type))
713 else {
714 continue;
715 };
716 if let Some(old) = previous.and_then(|p| p.csr.get(rel_type)) {
717 if old.source.same_data(source) {
718 entries.insert(rel_type.clone(), old.clone());
719 continue;
720 }
721 }
722 let field = |column: &str| {
723 schemas[name]
724 .fields()
725 .iter()
726 .find(|f| f.name().eq_ignore_ascii_case(column))
727 .cloned()
728 };
729 let (Some(src), Some(dst)) = (
730 field(&mapping.source_id_field),
731 field(&mapping.target_id_field),
732 ) else {
733 continue;
734 };
735 if !is_supported_id_type(src.data_type()) || !is_supported_id_type(dst.data_type()) {
736 continue;
737 }
738
739 let previous_entry = previous.and_then(|p| p.csr.get(rel_type));
740 if let Some(old) = previous_entry {
741 if let Some(entry) = try_apply_csr_delta(old, source, src.name(), dst.name()).await {
742 entries.insert(rel_type.clone(), entry);
743 continue;
744 }
745 }
746
747 let started = std::time::Instant::now();
748 let table = name.to_lowercase();
749 let scan = || async {
751 ctx.table(table.as_str())
752 .await?
753 .select_columns(&[src.name().as_str(), dst.name().as_str()])?
754 .execute_stream()
755 .await
756 .map_err(|e| exec_error(format!("Failed to scan edges for CSR: {}", e)))
757 };
758 let outgoing = Adjacency::build(scan().await?, src.name(), dst.name()).await?;
759 let incoming = Adjacency::build(scan().await?, dst.name(), src.name()).await?;
760 entries.insert(
761 rel_type.clone(),
762 CsrEntry {
763 source: source.clone(),
764 outgoing: Arc::new(outgoing),
765 incoming: Arc::new(incoming),
766 build_ms: started.elapsed().as_secs_f64() * 1e3,
767 update: if previous_entry.is_some() {
768 "rebuild"
769 } else {
770 "build"
771 },
772 },
773 );
774 }
775 Ok(entries)
776}
777
778pub struct CypherEngine {
793 config: GraphConfig,
794 state: RwLock<Arc<EngineState>>,
795 use_csr: bool,
796 target_partitions: Option<usize>,
797 refresh_lock: futures::lock::Mutex<()>,
799}
800
801impl CypherEngine {
802 pub async fn new(
814 config: GraphConfig,
815 sources: HashMap<String, EngineSource>,
816 use_csr: bool,
817 ) -> Result<Self> {
818 Self::with_target_partitions(config, sources, use_csr, None).await
819 }
820
821 pub async fn with_target_partitions(
832 config: GraphConfig,
833 sources: HashMap<String, EngineSource>,
834 use_csr: bool,
835 target_partitions: Option<usize>,
836 ) -> Result<Self> {
837 if sources.is_empty() {
838 return Err(config_error("No input datasets provided".to_string()));
839 }
840 if let Some(partitions) = target_partitions {
841 if !(1..=MAX_TARGET_PARTITIONS).contains(&partitions) {
842 return Err(config_error(format!(
843 "target_partitions must be between 1 and {MAX_TARGET_PARTITIONS}"
844 )));
845 }
846 }
847 let state = build_engine_state(&config, sources, use_csr, target_partitions, None).await?;
848 Ok(Self {
849 config,
850 state: RwLock::new(Arc::new(state)),
851 use_csr,
852 target_partitions,
853 refresh_lock: Default::default(),
854 })
855 }
856
857 fn snapshot(&self) -> Arc<EngineState> {
859 self.state
860 .read()
861 .expect("engine state lock poisoned")
862 .clone()
863 }
864
865 fn parse(
868 &self,
869 text: &str,
870 params: HashMap<String, JsonValue>,
871 state: &EngineState,
872 use_cache: bool,
873 ) -> Result<CypherQuery> {
874 let parameters: HashMap<String, JsonValue> = params
875 .into_iter()
876 .map(|(name, value)| (name.to_lowercase(), value))
877 .collect();
878 let key = use_cache.then(|| parameter_types(¶meters)).flatten();
879 if let Some(key) = &key {
880 let mut plans = state.plans.lock().expect("plan cache lock poisoned");
881 if let Some(index) = plans
882 .iter()
883 .position(|entry| entry.text == text && entry.parameter_types == *key)
884 {
885 let entry = plans.remove(index).expect("index from position");
886 let query = entry.query.clone().with_parameters(parameters);
887 plans.push_back(entry);
888 return Ok(query);
889 }
890 }
891 let query = CypherQuery::new(text)?
892 .with_parameters(parameters)
893 .with_config(self.config.clone())
894 .with_fan_out(state.fan_out());
895 let mut query = if self.use_csr {
896 query.with_adjacency(state.adjacency_catalog())
897 } else {
898 query
899 };
900 if let Some(parameter_types) = key {
901 if query
904 .prepare_cached_plan(state.catalog.clone())
905 .unwrap_or(false)
906 {
907 let mut plans = state.plans.lock().expect("plan cache lock poisoned");
908 plans
909 .retain(|entry| entry.text != text || entry.parameter_types != parameter_types);
910 if plans.len() == PLAN_CACHE_ENTRIES {
911 plans.pop_front();
912 }
913 plans.push_back(CachedQuery {
914 text: text.to_string(),
915 parameter_types,
916 query: query.clone(),
917 });
918 }
919 }
920 Ok(query)
921 }
922
923 pub async fn execute(
928 &self,
929 query: &str,
930 params: HashMap<String, JsonValue>,
931 ) -> Result<RecordBatch> {
932 let state = self.snapshot();
933 self.parse(query, params, &state, true)?
934 .execute_with_catalog_and_context(state.catalog.clone(), state.context.clone())
935 .await
936 }
937
938 pub async fn execute_stream(
946 &self,
947 query: &str,
948 params: HashMap<String, JsonValue>,
949 ) -> Result<SendableRecordBatchStream> {
950 let state = self.snapshot();
951 self.parse(query, params, &state, true)?
952 .execute_stream_with_catalog_and_context(state.catalog.clone(), state.context.clone())
953 .await
954 }
955
956 pub async fn explain(&self, query: &str, params: HashMap<String, JsonValue>) -> Result<String> {
958 let state = self.snapshot();
959 self.parse(query, params, &state, false)?
960 .explain_with_catalog_and_context(state.catalog.clone(), state.context.clone())
961 .await
962 }
963
964 pub async fn execute_with_vector_rerank(
966 &self,
967 query: &str,
968 vector_search: &VectorSearch,
969 ) -> Result<RecordBatch> {
970 let state = self.snapshot();
971 let candidates = self
972 .parse(query, HashMap::new(), &state, false)?
973 .execute_with_catalog_and_context(state.catalog.clone(), state.context.clone())
974 .await?;
975 vector_search.search(&candidates).await
976 }
977
978 pub async fn refresh(&self) -> Result<()> {
986 let _refreshing = self.refresh_lock.lock().await;
987 let current = self.snapshot();
988 let mut sources = current.sources.clone();
989 for source in sources.values_mut() {
990 if let EngineSource::Lance {
991 uri,
992 storage_options,
993 dataset,
994 } = source
995 {
996 let latest = open_lance_dataset(uri, None, storage_options).await?;
997 if !same_lance_snapshot(&latest, dataset) {
998 *dataset = Arc::new(latest);
999 }
1000 }
1001 }
1002 let state = build_engine_state(
1003 &self.config,
1004 sources,
1005 self.use_csr,
1006 self.target_partitions,
1007 Some(¤t),
1008 )
1009 .await?;
1010 *self.state.write().expect("engine state lock poisoned") = Arc::new(state);
1011 Ok(())
1012 }
1013
1014 pub fn csr_stats(&self) -> HashMap<String, CsrStats> {
1018 self.snapshot()
1019 .csr
1020 .iter()
1021 .map(|(rel_type, entry)| {
1022 let stats = CsrStats {
1023 edges: entry.outgoing.num_edges(),
1024 delta_edges: entry.outgoing.delta_edges(),
1025 tombstones: entry.outgoing.tombstones(),
1026 update: entry.update,
1027 version: entry.source.lance_version(),
1028 build_ms: entry.build_ms,
1029 };
1030 (rel_type.clone(), stats)
1031 })
1032 .collect()
1033 }
1034
1035 pub fn node_index_stats(&self) -> HashMap<String, NodeIndexStats> {
1040 self.snapshot()
1041 .node_index
1042 .iter()
1043 .map(|(label, entry)| {
1044 let stats = NodeIndexStats {
1045 rows: entry.rows.num_rows(),
1046 version: entry.source.lance_version(),
1047 build_ms: entry.build_ms,
1048 };
1049 (label.clone(), stats)
1050 })
1051 .collect()
1052 }
1053
1054 pub fn dataset_versions(&self) -> HashMap<String, u64> {
1056 self.snapshot()
1057 .sources
1058 .iter()
1059 .filter_map(|(name, source)| Some((name.clone(), source.lance_version()?)))
1060 .collect()
1061 }
1062
1063 pub fn config(&self) -> &GraphConfig {
1065 &self.config
1066 }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use arrow_array::{Int64Array, RecordBatchIterator};
1073 use arrow_schema::{DataType, Field, Schema};
1074 use lance::dataset::{WriteMode, WriteParams};
1075
1076 fn people_engine_sources() -> HashMap<String, EngineSource> {
1077 let batch = RecordBatch::try_from_iter([(
1078 "id",
1079 Arc::new(Int64Array::from(vec![1, 2, 3])) as arrow_array::ArrayRef,
1080 )])
1081 .unwrap();
1082 HashMap::from([("Person".to_string(), EngineSource::Arrow(batch))])
1083 }
1084
1085 fn person_config() -> GraphConfig {
1086 GraphConfig::builder()
1087 .node_table("Person", "id")
1088 .build()
1089 .unwrap()
1090 }
1091
1092 #[tokio::test]
1093 async fn target_partitions_survive_refresh_without_changing_other_defaults() {
1094 let defaults = SessionConfig::new();
1095 for target_partitions in [None, Some(1), Some(2)] {
1096 let engine = CypherEngine::with_target_partitions(
1097 person_config(),
1098 people_engine_sources(),
1099 false,
1100 target_partitions,
1101 )
1102 .await
1103 .unwrap();
1104 let before = engine.snapshot();
1105 engine.refresh().await.unwrap();
1106 for snapshot in [before, engine.snapshot()] {
1107 let state = snapshot.context.state();
1108 assert_eq!(
1109 state.config().target_partitions(),
1110 target_partitions.unwrap_or(defaults.target_partitions())
1111 );
1112 assert_eq!(
1113 state.config().round_robin_repartition(),
1114 defaults.round_robin_repartition()
1115 );
1116 }
1117 }
1118 for out_of_range in [0, MAX_TARGET_PARTITIONS + 1] {
1119 let result = CypherEngine::with_target_partitions(
1120 person_config(),
1121 people_engine_sources(),
1122 false,
1123 Some(out_of_range),
1124 )
1125 .await;
1126 assert!(matches!(result, Err(GraphError::ConfigError { .. })));
1127 }
1128 }
1129
1130 #[tokio::test]
1131 async fn plan_cache_reuses_bounds_and_resets_on_refresh() {
1132 let engine = CypherEngine::new(person_config(), people_engine_sources(), false)
1133 .await
1134 .unwrap();
1135 let snapshot = engine.snapshot();
1136 let cached = || snapshot.plans.lock().unwrap().len();
1137 let text = "MATCH (p:Person) WHERE p.id = $value RETURN p.id";
1138 for value in [1, 2, 3] {
1139 let params = HashMap::from([("value".to_string(), JsonValue::from(value))]);
1140 engine.parse(text, params, &snapshot, true).unwrap();
1141 assert_eq!(cached(), 1);
1142 assert_eq!(
1145 snapshot.plans.lock().unwrap()[0].query.parameters()["value"],
1146 JsonValue::from(1)
1147 );
1148 }
1149 for value in [JsonValue::Null, JsonValue::from(1.5)] {
1151 let params = HashMap::from([("value".to_string(), value)]);
1152 engine.parse(text, params, &snapshot, true).unwrap();
1153 }
1154 assert_eq!(cached(), 3);
1155 let ids = HashMap::from([("ids".to_string(), serde_json::json!([1, 2]))]);
1157 engine
1158 .parse(
1159 "MATCH (p:Person) WHERE p.id IN $ids RETURN p.id",
1160 ids,
1161 &snapshot,
1162 true,
1163 )
1164 .unwrap();
1165 assert_eq!(cached(), 3);
1166 engine
1168 .parse(
1169 "MATCH (p:Person) RETURN p.id",
1170 HashMap::new(),
1171 &snapshot,
1172 false,
1173 )
1174 .unwrap();
1175 assert_eq!(cached(), 3);
1176 for limit in 0..130 {
1177 let text = format!("MATCH (p:Person) RETURN p.id AS id LIMIT {limit}");
1178 engine
1179 .parse(&text, HashMap::new(), &snapshot, true)
1180 .unwrap();
1181 }
1182 assert_eq!(cached(), PLAN_CACHE_ENTRIES);
1183 assert!(snapshot
1184 .plans
1185 .lock()
1186 .unwrap()
1187 .iter()
1188 .all(|e| e.text != text));
1189
1190 let params = || HashMap::from([("value".to_string(), JsonValue::from(2))]);
1192 let first = engine.execute(text, params()).await.unwrap();
1193 let again = engine.execute(text, params()).await.unwrap();
1194 assert_eq!(first, again);
1195 assert_eq!(first.num_rows(), 1);
1196
1197 engine.refresh().await.unwrap();
1199 assert!(engine.snapshot().plans.lock().unwrap().is_empty());
1200 assert_eq!(cached(), PLAN_CACHE_ENTRIES);
1201 }
1202
1203 #[tokio::test]
1204 async fn execution_deadline_stops_nested_loop_joins() {
1205 let engine = CypherEngine::new(person_config(), people_engine_sources(), false)
1206 .await
1207 .unwrap();
1208 let query = "MATCH (a:Person), (b:Person) WHERE a.id < b.id RETURN count(*) AS n";
1210 let expired = crate::query::with_execution_deadline(
1211 Some(tokio::time::Instant::now()),
1212 engine.execute(query, HashMap::new()),
1213 )
1214 .await;
1215 let message = expired.unwrap_err().to_string();
1216 assert!(message.contains("Query timeout exceeded"), "{message}");
1217 let later = tokio::time::Instant::now() + std::time::Duration::from_secs(60);
1219 for deadline in [None, Some(later)] {
1220 let batch = crate::query::with_execution_deadline(
1221 deadline,
1222 engine.execute(query, HashMap::new()),
1223 )
1224 .await
1225 .unwrap();
1226 assert_eq!(batch.num_rows(), 1);
1227 }
1228 }
1229
1230 async fn write_people(uri: &str, ids: Vec<i64>, mode: WriteMode) {
1231 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1232 let batch =
1233 RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(ids))]).unwrap();
1234 let params = WriteParams {
1235 mode,
1236 ..Default::default()
1237 };
1238 Dataset::write(
1239 RecordBatchIterator::new([Ok(batch)], schema),
1240 uri,
1241 Some(params),
1242 )
1243 .await
1244 .unwrap();
1245 }
1246
1247 #[tokio::test]
1250 async fn refresh_waits_for_an_in_flight_refresh() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let uri = dir.path().join("person.lance").display().to_string();
1253 write_people(&uri, vec![1], WriteMode::Create).await;
1254 let config = GraphConfig::builder()
1255 .node_table("Person", "id")
1256 .build()
1257 .unwrap();
1258 let source = EngineSource::open_lance(uri.clone(), None, HashMap::new())
1259 .await
1260 .unwrap();
1261 let sources = HashMap::from([("Person".to_string(), source)]);
1262 let engine = Arc::new(CypherEngine::new(config, sources, true).await.unwrap());
1263
1264 let in_flight = engine.refresh_lock.lock().await;
1266 let b = tokio::spawn({
1267 let engine = engine.clone();
1268 async move { engine.refresh().await }
1269 });
1270 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1271 assert!(!b.is_finished(), "refresh B ran while A was in flight");
1272
1273 write_people(&uri, vec![2], WriteMode::Append).await;
1275 drop(in_flight);
1276 b.await.unwrap().unwrap();
1277 assert_eq!(engine.dataset_versions()["Person"], 2);
1278 assert_eq!(engine.node_index_stats()["Person"].rows, 2);
1279 }
1280}