Skip to main content

lance_csr/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! A Cypher engine that registers its datasets once and serves many queries.
5//!
6//! [`CypherEngine`] holds a snapshot of every input: the DataFusion context, the
7//! catalog, join-key statistics and, with `use_csr`, the CSR adjacencies and node
8//! indexes. Queries share the snapshot read-only; [`CypherEngine::refresh`] builds a
9//! complete replacement and swaps it in.
10//!
11//! ```no_run
12//! use std::collections::HashMap;
13//! use lance_csr::engine::{CypherEngine, EngineSource};
14//! use lance_csr::GraphConfig;
15//!
16//! # async fn example() -> lance_csr::Result<()> {
17//! let config = GraphConfig::builder()
18//!     .node_table("Person", "id")
19//!     .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
20//!     .build()?;
21//! let sources = HashMap::from([
22//!     ("Person".to_string(), EngineSource::open_lance("data/person.lance", None, HashMap::new()).await?),
23//!     ("KNOWS".to_string(), EngineSource::open_lance("data/knows.lance", None, HashMap::new()).await?),
24//! ]);
25//! let engine = CypherEngine::new(config, sources, true).await?;
26//! let batch = engine
27//!     .execute("MATCH (a:Person)-[:KNOWS]->(b) RETURN a.name, b.name", HashMap::new())
28//!     .await?;
29//! # Ok(())
30//! # }
31//! ```
32
33use 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/// One input dataset as registered with a [`CypherEngine`].
61#[derive(Clone)]
62pub enum EngineSource {
63    /// On-disk Lance dataset, read lazily through `LanceTableProvider`.
64    ///
65    /// `dataset` is pinned to one version; only `refresh()` replaces it. `uri` and
66    /// `storage_options` are kept so refresh can reopen the same location with the
67    /// same credentials.
68    Lance {
69        uri: String,
70        storage_options: HashMap<String, String>,
71        dataset: Arc<Dataset>,
72    },
73    /// In-memory Arrow data supplied by the caller. Treated as immutable: replacing
74    /// it requires a new engine.
75    Arrow(RecordBatch),
76}
77
78impl EngineSource {
79    /// Open a Lance dataset at `uri`, pinned to `version` (`None` means the latest).
80    ///
81    /// `storage_options` are kept so [`CypherEngine::refresh`] can reopen the same
82    /// location with the same credentials.
83    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    /// Pinned Lance version, or `None` for Arrow input.
98    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    /// True when both refer to the same immutable data (same pinned Lance handle or
106    /// same Arrow batch), so anything derived from one is valid for the other.
107    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    /// Row count: free for Arrow input, from Lance metadata otherwise.
122    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/// CSR adjacency statistics for one relationship type; see [`CypherEngine::csr_stats`].
134#[derive(Debug, Clone, PartialEq)]
135pub struct CsrStats {
136    /// Live indexed edges (null endpoints are skipped).
137    pub edges: u64,
138    /// Appended edges held outside the base CSR since its last full build.
139    pub delta_edges: u64,
140    /// Removed edges held outside the base CSR since its last full build.
141    pub tombstones: u64,
142    /// How the last refresh produced it: `build` (first), `rebuild` (full), `delta`
143    /// (changes applied) or `reused` (no edge change).
144    pub update: &'static str,
145    /// Relationship dataset version it reflects (`None` for Arrow input).
146    pub version: Option<u64>,
147    /// Wall time of that build or delta, both directions.
148    pub build_ms: f64,
149}
150
151/// Node id -> row index statistics for one node label; see [`CypherEngine::node_index_stats`].
152#[derive(Debug, Clone, PartialEq)]
153pub struct NodeIndexStats {
154    /// Indexed node rows.
155    pub rows: usize,
156    /// Node dataset version it reflects (`None` for Arrow input).
157    pub version: Option<u64>,
158    /// Wall time of the build.
159    pub build_ms: f64,
160}
161
162/// Everything a query needs, built once and shared read-only by concurrent queries.
163///
164/// `refresh()` builds a complete replacement and swaps it in; queries already running
165/// keep the `Arc` they started with, so they finish on their original snapshot.
166struct EngineState {
167    sources: HashMap<String, EngineSource>,
168    /// Unoptimized plan templates for this snapshot; a refresh starts an empty cache.
169    plans: Mutex<VecDeque<CachedQuery>>,
170    catalog: Arc<dyn GraphSourceCatalog>,
171    context: SessionContext,
172    /// CSR adjacencies by relationship type; empty unless the engine uses CSR.
173    csr: HashMap<String, CsrEntry>,
174    /// Node id -> row indexes by node label, for the fused target lookup; same lifecycle.
175    node_index: HashMap<String, NodeIndexEntry>,
176    /// Join-key distinct counts by dataset name, reported to DataFusion's join planner.
177    key_stats: HashMap<String, KeyStatsEntry>,
178}
179
180/// Plan templates kept per snapshot.
181const PLAN_CACHE_ENTRIES: usize = 128;
182
183/// A small LRU of unoptimized plans. Values are bound after lookup, before optimization.
184struct CachedQuery {
185    text: String,
186    parameter_types: Vec<(String, u8)>,
187    query: CypherQuery,
188}
189
190// Containers change expression shape; keep them on the uncached path.
191fn 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/// Distinct counts for one dataset's node-id and edge-endpoint columns.
211#[derive(Clone)]
212struct KeyStatsEntry {
213    /// Dataset the counts describe; reused while it is the same snapshot.
214    source: EngineSource,
215    /// Actual column name -> distinct non-null values.
216    distinct: HashMap<String, usize>,
217    /// For a relationship dataset: mean fan-out by direction (outgoing, incoming).
218    fan_out: Option<(String, f64, f64)>,
219}
220
221/// Id -> row index for one node table, plus the source it came from.
222#[derive(Clone)]
223struct NodeIndexEntry {
224    source: EngineSource,
225    rows: Arc<NodeRows>,
226    build_ms: f64,
227}
228
229/// Both-direction adjacencies for one relationship type, plus the source they came from.
230#[derive(Clone)]
231struct CsrEntry {
232    /// Dataset the adjacencies reflect; reused while it is the same snapshot.
233    source: EngineSource,
234    outgoing: Arc<Adjacency>,
235    incoming: Arc<Adjacency>,
236    /// Wall time of the last build or delta application.
237    build_ms: f64,
238    /// How the last refresh produced this entry: build, rebuild, delta or reused.
239    update: &'static str,
240}
241
242/// Rebuild instead of applying a delta once pending changes (appended edges +
243/// tombstones, accumulated since the last full build) would exceed this fraction
244/// of the base. Lookups pay for the delta on every query, and a rebuild folds it in.
245///
246/// This threshold also bounds refresh cost: each delta refresh copies the whole
247/// accumulated delta (see the design note on `crate::csr_expand::Adjacency`).
248/// Raising it makes refreshes slower as well as lookups.
249// shortcut: fixed threshold, chosen from python/benchmarks/csr_delta_accumulated.py; tune per workload if deltas grow large.
250const CSR_REBUILD_FRACTION: f64 = 0.25;
251
252/// Upper bound for `target_partitions`: far above any core count, below values whose
253/// per-partition setup would exhaust memory instead of returning an error.
254pub const MAX_TARGET_PARTITIONS: usize = 1024;
255
256/// Apply the edge changes between `old`'s snapshot and `source` as a delta.
257///
258/// `None` means rebuild: not both Lance snapshots, the diff is not meaningful
259/// or failed (e.g. old files already cleaned up), or the delta would be too large.
260async 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    /// Mean fan-out of every relationship type with key statistics, both directions.
311    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
351/// Open a Lance dataset. `version = None` means the latest version.
352async 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
367/// Same version number and manifest commit time means the same snapshot.
368///
369/// The timestamp distinguishes a table deleted and recreated at the same location,
370/// which restarts version numbering at 1.
371fn 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
376/// Check that every configured id/endpoint column exists in its dataset.
377///
378/// Labels and columns compare case-insensitively, matching the planner. Mappings
379/// without a dataset are left for query time, as before, so unused labels stay legal.
380fn 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
412/// Validate sources and register them in a fresh catalog and session context.
413async 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    // The CSR planner only changes plans that contain CSR nodes, so install it always.
425    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            // Move target-only filters into CSR nodes first, so pruning can then drop
431            // the filter columns from the take.
432            .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            // No _rowid/_rowaddr columns, so Lance and Arrow inputs expose the same schema.
442            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        // Registered unwrapped first: the CSR and key-statistics builds below scan it.
453        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        // Re-register with join-key statistics, so plans see them; see `key_statistics`.
472        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        // Every dataset is registered as both a node and a relationship source; the
482        // GraphConfig and query pattern decide which interpretation the planner uses.
483        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
499/// Build (or reuse) an id -> row index for every node label with unique integer ids.
500///
501/// Reused while the node dataset is the same snapshot; edge changes never touch it.
502/// Labels whose ids are not integers or not unique get no entry, so their targets
503/// keep the hash join.
504async 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
546/// Rows per in-memory batch, DataFusion's default `batch_size`.
547const MEMTABLE_BATCH_ROWS: usize = 8192;
548
549/// Split one in-memory table into zero-copy slices spread over one partition per core.
550///
551/// A `MemTable` holding a single large batch is one partition with one batch, and
552/// `RoundRobinBatch` repartitioning moves whole batches, so every operator that does
553/// not hash-repartition (such as `CsrExpandExec`) would run on one thread over all of
554/// it. Slicing shares the input's buffers; nothing is copied.
555fn 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
572/// Distinct counts for every dataset's node-id and edge-endpoint columns.
573///
574/// DataFusion 54 sizes hash joins from these (see `crate::key_statistics`).
575/// Node ids count as unique (the row count). Edge endpoints use the CSR's distinct
576/// source and target counts when the CSR is built, and otherwise one scan of the two
577/// endpoint columns; only integer endpoints are counted. Reused while a dataset is
578/// the same snapshot, so an unchanged `refresh()` costs nothing.
579// shortcut: the fallback scan reads every edge endpoint at construction and on each changed refresh (about one CSR build's scan); switch to an approximate counter (HyperLogLog) or Lance column statistics if that shows up in startup time.
580async 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        // Relationship type and endpoint columns, for the fan-out once counts are known.
606        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                // Built from this same snapshot in this refresh, so its counts are current.
632                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
694/// Build (or reuse) CSR adjacencies for every relationship type with integer endpoints.
695///
696/// An entry from `previous` is reused when its relationship dataset is the same
697/// snapshot; node-table changes never invalidate it, since the CSR stores raw
698/// target ids. Relationship types with non-integer endpoints get no entry and
699/// queries over them use the join path.
700async 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        // Two projected scans of the endpoint columns, one per direction.
750        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
778/// Cypher query engine with a cached catalog for efficient multi-query execution.
779///
780/// The engine registers each dataset once and reuses the catalog and DataFusion
781/// context across queries.
782///
783/// Lance datasets are read lazily from storage, pinned to the version they were
784/// opened at: writes made later (by this or another process) are not visible until
785/// [`refresh`](Self::refresh). Each dataset is pinned separately; versions across
786/// tables are not a transactional multi-table snapshot. Arrow batches are held in
787/// memory and never change; build a new engine to replace them.
788///
789/// Building the CSR (`use_csr`) is CPU-bound work inside an async function: in an
790/// application with its own async runtime, construct and refresh the engine on a
791/// blocking thread (for example `tokio::task::spawn_blocking` plus `block_on`).
792pub struct CypherEngine {
793    config: GraphConfig,
794    state: RwLock<Arc<EngineState>>,
795    use_csr: bool,
796    target_partitions: Option<usize>,
797    /// Held for a whole refresh, so a slower refresh can't swap in older data over a newer one.
798    refresh_lock: futures::lock::Mutex<()>,
799}
800
801impl CypherEngine {
802    /// Register `sources` (dataset name -> data) and build the snapshot.
803    ///
804    /// Lance datasets are not read here, except for the CSR, node-index and
805    /// join-key statistics builds. With `use_csr`, relationship types with integer
806    /// endpoints get CSR adjacencies and node labels with unique integer ids get a
807    /// node index.
808    ///
809    /// # Errors
810    /// [`GraphError::ConfigError`] if `sources` is empty or a configured id/endpoint
811    /// column is missing; [`GraphError::ExecutionError`] if a dataset cannot be
812    /// registered or scanned.
813    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    /// Like [`new`](Self::new), with a target for DataFusion query partitioning.
822    ///
823    /// `Some(n)` sets DataFusion's `target_partitions` for every query, kept across
824    /// [`refresh`](Self::refresh); `None` keeps DataFusion's default (the number of
825    /// cores). It is not a thread or memory limit. In-memory Arrow inputs are still
826    /// sliced into one partition per core.
827    ///
828    /// # Errors
829    /// As [`new`](Self::new), plus [`GraphError::ConfigError`] if `target_partitions`
830    /// is outside `1..=1024`.
831    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    /// The current snapshot. Cloning the `Arc` lets a query outlive a concurrent refresh.
858    fn snapshot(&self) -> Arc<EngineState> {
859        self.state
860            .read()
861            .expect("engine state lock poisoned")
862            .clone()
863    }
864
865    /// Parse and configure a query, reusing this snapshot's plan template when
866    /// `use_cache` is set and every parameter is a scalar.
867    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(&parameters)).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            // Some operators require literal values during planning. If a template
902            // cannot be built, ordinary planning preserves their existing behavior.
903            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    /// Execute a Cypher query and return the whole result as one batch.
924    ///
925    /// `params` gives values for `$name` parameters in the query. Use
926    /// [`execute_stream`](Self::execute_stream) for large outputs.
927    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    /// Execute a Cypher query and stream the result.
939    ///
940    /// Batches are computed as the stream is polled. Scans, filters and projections
941    /// stream with bounded memory; joins, sorts and aggregations may still buffer.
942    /// The stream keeps what it reads alive, so a concurrent `refresh()` does not
943    /// affect it. Planning errors return here; execution errors arrive in the stream.
944    /// Dropping the stream cancels the remaining work.
945    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    /// Return the query plan, including the physical plan with pushed-down scans.
957    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    /// Execute a Cypher query, then rerank its rows by vector similarity.
965    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    /// Move every Lance dataset to its latest version.
979    ///
980    /// Datasets whose version and commit time are unchanged keep their open handle.
981    /// The replacement is validated in full before it is swapped in; on error nothing
982    /// changes and the previous snapshot stays usable. Queries already running finish
983    /// on the snapshot they started with. Arrow inputs are unchanged. Concurrent calls
984    /// run one at a time.
985    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(&current),
1008        )
1009        .await?;
1010        *self.state.write().expect("engine state lock poisoned") = Arc::new(state);
1011        Ok(())
1012    }
1013
1014    /// CSR adjacency statistics per relationship type (experimental, `use_csr`).
1015    ///
1016    /// An identical map after `refresh()` means the snapshot itself was unchanged.
1017    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    /// Node id -> row index statistics per node label (experimental, `use_csr`).
1036    ///
1037    /// A label is missing when its ids are not unique integers; its targets then use
1038    /// a join.
1039    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    /// Pinned version of each Lance dataset, keyed by dataset name.
1055    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    /// The graph configuration the engine was built with.
1064    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            // The retained template keeps its first parameters: later calls looked it
1143            // up and rebound values rather than replacing the entry.
1144            assert_eq!(
1145                snapshot.plans.lock().unwrap()[0].query.parameters()["value"],
1146                JsonValue::from(1)
1147            );
1148        }
1149        // A different scalar type is a different template.
1150        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        // Container parameters stay on the uncached path.
1156        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        // Explain-style parses never cache.
1167        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        // Cached and uncached plans return the same rows.
1191        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        // A refresh publishes a new snapshot with an empty cache; the old one is untouched.
1198        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        // A non-equality join plans as a nested-loop join, whose filter checks the deadline.
1209        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        // No deadline, or a distant one, leaves the same query untouched.
1218        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    /// A refresh that starts while another is in flight waits for it, then publishes
1248    /// the newest version: an older snapshot can never land after a newer one.
1249    #[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        // Stand in for refresh A, still building its replacement.
1265        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        // A newer version lands before A finishes; B must publish it, not anything older.
1274        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}