Skip to main content

lance_csr/
csr_expand.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Experimental CSR-backed single-hop expansion.
5//!
6//! The join path expands `(a)-[:R]->(b)` by scanning the whole relationship table
7//! and hash-joining it to `a`. When an [`Adjacency`] for `R` is supplied, the planner
8//! instead emits a [`CsrExpandNode`]: for each input row it looks up `a`'s neighbours
9//! in the CSR and appends the relationship's two endpoint columns, exactly as the
10//! relationship scan would have produced them.
11//!
12//! When a [`NodeRows`] index exists for the target label, the node also replaces the
13//! target join: each neighbour id is mapped to its row in the target node table and
14//! those rows are fetched by position (Lance `take_rows`, or Arrow `take` for
15//! in-memory tables). Neighbours with no target row are dropped, as the inner join
16//! would drop them. Otherwise the target join is planned as before.
17//!
18//! The adjacency is built from the relationship table alone:
19//! - source endpoint values are mapped to dense CSR vertex positions in a hash map;
20//! - neighbours store the raw target endpoint values (`i64` bit-cast to `u64`), so
21//!   no node-table scan or second map is needed, and node-table writes never
22//!   invalidate it. Rebuild only when the relationship table changes.
23//!
24//! The node index depends only on its node table, so the two are rebuilt
25//! independently: edge commits rebuild the CSR, node commits rebuild the index.
26//!
27//! Only integer endpoint columns are supported; other shapes keep the join path.
28
29use std::cmp::Ordering;
30use std::collections::{HashMap, HashSet};
31use std::fmt;
32use std::hash::{Hash, Hasher};
33use std::sync::Arc;
34
35use arrow::array::{
36    new_empty_array, Array, ArrayRef, AsArray, Int64Array, RecordBatch, UInt32Array, UInt64Array,
37};
38use arrow::compute::{cast, filter as filter_array, take};
39use arrow::datatypes::{DataType, Field, Int64Type, Schema, SchemaRef};
40use async_trait::async_trait;
41use datafusion::common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
42use datafusion::common::Column;
43use datafusion::common::{DFSchema, DFSchemaRef};
44use datafusion::execution::context::{QueryPlanner, SessionState};
45use datafusion::execution::{SendableRecordBatchStream, TaskContext};
46use datafusion::logical_expr::utils::{conjunction, split_conjunction};
47use datafusion::logical_expr::{
48    Expr, LogicalPlan, UserDefinedLogicalNode, UserDefinedLogicalNodeCore,
49};
50use datafusion::logical_expr::{Extension, LogicalPlanBuilder};
51use datafusion::optimizer::{OptimizerConfig, OptimizerRule};
52use datafusion::physical_expr::{EquivalenceProperties, PhysicalExpr};
53use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
54use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
55use datafusion::physical_plan::{
56    DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, Partitioning,
57    PlanProperties,
58};
59use datafusion::physical_planner::{DefaultPhysicalPlanner, ExtensionPlanner, PhysicalPlanner};
60use futures::StreamExt;
61use lance::dataset::{Dataset, ProjectionRequest};
62use lance_table::format::Fragment;
63
64/// Yield after bounded CPU work without requiring a particular async runtime.
65pub(crate) async fn cooperative_yield() {
66    let mut yielded = false;
67    std::future::poll_fn(|cx| {
68        if yielded {
69            std::task::Poll::Ready(())
70        } else {
71            yielded = true;
72            cx.waker().wake_by_ref();
73            std::task::Poll::Pending
74        }
75    })
76    .await
77}
78
79/// Lance's row-address column (`lance_core::ROW_ADDR`, not re-exported by `lance`).
80const ROW_ADDR: &str = "_rowaddr";
81
82use crate::case_insensitive::qualify_column;
83
84/// Integer-keyed maps on the per-edge lookup path. With std's default hasher
85/// (SipHash), hashing dominated CSR expansion time (profiled on LDBC Q30); ahash is
86/// what Arrow and DataFusion use for their own hash tables.
87type FastMap<K, V> = HashMap<K, V, ahash::RandomState>;
88use crate::csr_index::{CsrIndex, CsrIndexBuilder};
89use crate::error::{GraphError, Result};
90
91/// Neighbour lookup for one relationship type in one direction.
92///
93/// An immutable CSR base, shared between snapshots via `Arc`, plus one delta applied
94/// by [`Adjacency::with_changes`]: appended edges per source key, and tombstones as
95/// sorted offsets into each source's base-plus-appended neighbours. Offsets let
96/// readers skip deleted occurrences without copying per-source deletion state.
97/// Identical edges remain distinct occurrences; the base stays at 8 bytes per edge.
98///
99/// # Design note for future changes (read before "optimizing" the delta)
100///
101/// `with_changes` copies the whole delta (not the base) on every refresh, so refresh
102/// cost grows with the delta accumulated since the last full build, not just with
103/// the new commit. This is deliberate. Snapshots must stay immutable because
104/// in-flight queries keep using the previous one, so the delta cannot be mutated in
105/// place. The copy is bounded: the engine rebuilds once the delta exceeds
106/// `CSR_REBUILD_FRACTION` (25%) of the base.
107/// Deletions additionally scan each affected source's neighbours once to resolve
108/// occurrence offsets; queries can then resume without allocating deletion maps.
109///
110/// Measured at 10M edges (`python/benchmarks/csr_delta_accumulated.py`, 20 × 1%
111/// appends): refresh grows from about 11 ms to about 80 ms as the delta reaches 20%,
112/// against 1.2–1.6 s for a full rebuild. Two alternatives were tried or assessed
113/// and rejected:
114/// - shared, immutable delta layers merged like a binary counter: median refresh
115///   74 → 32 ms, but merge cascades spiked to 298 ms, lookups got 5–25% slower, and
116///   the code was about 60 lines larger;
117/// - a persistent hash map (`imbl`): no spikes and one lookup, but about five new
118///   crates and `unsafe` code to trust.
119///
120/// Revisit if refreshes become frequent on graphs far larger than 10M edges, or if
121/// the rebuild threshold is raised.
122pub struct Adjacency {
123    base: Arc<CsrBase>,
124    added: FastMap<i64, Vec<u64>>,
125    removed: FastMap<i64, Vec<usize>>,
126    added_count: u64,
127    removed_count: u64,
128}
129
130struct CsrBase {
131    /// Source endpoint value -> dense CSR vertex position.
132    dense: FastMap<i64, u64>,
133    csr: CsrIndex,
134}
135
136impl Adjacency {
137    /// Build from a stream of relationship batches, reading edges `from -> to`.
138    ///
139    /// Column names match case-insensitively. Edges with a null endpoint are
140    /// skipped: they can never satisfy the equality join they replace.
141    pub async fn build(
142        mut stream: SendableRecordBatchStream,
143        from: &str,
144        to: &str,
145    ) -> Result<Self> {
146        let mut dense: FastMap<i64, u64> = FastMap::default();
147        let mut builder = CsrIndexBuilder::new();
148        while let Some(batch) = stream.next().await {
149            let batch = batch.map_err(|e| exec_error(format!("Failed to read edges: {}", e)))?;
150            let from_col = int64_column(&batch, from)?;
151            let to_col = int64_column(&batch, to)?;
152            for i in 0..batch.num_rows() {
153                if from_col.is_null(i) || to_col.is_null(i) {
154                    continue;
155                }
156                let next = dense.len() as u64;
157                let src = *dense.entry(from_col.value(i)).or_insert(next);
158                builder = builder.add_edge(src, to_col.value(i) as u64);
159            }
160        }
161        // Explicit vertex count: neighbours are raw ids, not positions, so the
162        // builder must not size its offsets from them.
163        let csr = builder.with_num_vertices(dense.len() as u64).build();
164        Ok(Self {
165            base: Arc::new(CsrBase { dense, csr }),
166            added: FastMap::default(),
167            removed: FastMap::default(),
168            added_count: 0,
169            removed_count: 0,
170        })
171    }
172
173    /// A new adjacency sharing this base, with `added` and `removed` edges
174    /// (`(from, to)` in this adjacency's direction) on top of the existing delta.
175    ///
176    /// Callers must only remove edges that are present. `self` is unchanged, so
177    /// snapshots still using it keep their answers. The base is never copied.
178    // shortcut: copies the accumulated delta on refresh; bounded by the engine's 25% rebuild threshold. Upgrade to a persistent map if refreshes get frequent on much larger graphs.
179    pub fn with_changes(&self, added: &[(i64, i64)], removed: &[(i64, i64)]) -> Self {
180        let mut next = Self {
181            base: self.base.clone(),
182            added: self.added.clone(),
183            removed: self.removed.clone(),
184            added_count: self.added_count + added.len() as u64,
185            removed_count: self.removed_count + removed.len() as u64,
186        };
187        for &(from, to) in added {
188            next.added.entry(from).or_default().push(to as u64);
189        }
190        let mut removals: FastMap<i64, FastMap<u64, usize>> = FastMap::default();
191        for &(from, to) in removed {
192            *removals
193                .entry(from)
194                .or_default()
195                .entry(to as u64)
196                .or_default() += 1;
197        }
198        for (from, mut counts) in removals {
199            let base = next.base_neighbors(from);
200            let added = next.added.get(&from).map_or(&[][..], Vec::as_slice);
201            let prior = next.removed.get(&from).map_or(&[][..], Vec::as_slice);
202            let mut prior = prior.iter().copied().peekable();
203            let mut positions = Vec::new();
204            for (position, neighbor) in base.iter().chain(added).enumerate() {
205                if prior.peek() == Some(&position) {
206                    prior.next();
207                    continue;
208                }
209                if let Some(count) = counts.get_mut(neighbor) {
210                    positions.push(position);
211                    *count -= 1;
212                    if *count == 0 {
213                        counts.remove(neighbor);
214                    }
215                    if counts.is_empty() {
216                        break;
217                    }
218                }
219            }
220            debug_assert!(counts.is_empty(), "removed edges must be present");
221            let tombstones = next.removed.entry(from).or_default();
222            tombstones.extend(positions);
223            tombstones.sort_unstable();
224        }
225        next
226    }
227
228    /// Call `f` for each target endpoint value reachable from `key`, duplicates included.
229    pub fn for_each_neighbor(&self, key: i64, f: impl FnMut(u64)) {
230        self.neighbors_from(key, &mut 0).for_each(f);
231    }
232
233    #[inline(always)]
234    fn base_neighbors(&self, key: i64) -> &[u64] {
235        self.base
236            .dense
237            .get(&key)
238            .map_or(&[], |&v| self.base.csr.neighbors(v))
239    }
240
241    /// Resume at an occurrence offset, borrowing all neighbour and deletion storage.
242    #[inline(always)]
243    fn neighbors_from<'a>(
244        &'a self,
245        key: i64,
246        offset: &'a mut usize,
247    ) -> impl Iterator<Item = u64> + 'a {
248        self.candidates_from(key, offset).flatten()
249    }
250
251    /// Keep tombstones visible to cancellation without repeating lookups for every edge.
252    #[inline(always)]
253    fn candidates_from<'a>(
254        &'a self,
255        key: i64,
256        offset: &'a mut usize,
257    ) -> impl Iterator<Item = Option<u64>> + 'a {
258        let base = self.base_neighbors(key);
259        let added = self.added.get(&key).map_or(&[][..], Vec::as_slice);
260        let removed = self.removed.get(&key).map_or(&[][..], Vec::as_slice);
261        let mut removed = &removed[removed.partition_point(|&position| position < *offset)..];
262        let added_offset = offset.saturating_sub(base.len());
263        base.get(*offset..)
264            .unwrap_or_default()
265            .iter()
266            .chain(added.get(added_offset..).unwrap_or_default())
267            .map(move |&neighbor| {
268                let position = *offset;
269                *offset += 1;
270                if removed.first() == Some(&position) {
271                    removed = &removed[1..];
272                    None
273                } else {
274                    Some(neighbor)
275                }
276            })
277    }
278
279    /// Live edges: base + appended - removed.
280    pub fn num_edges(&self) -> u64 {
281        self.base.csr.num_edges() + self.added_count - self.removed_count
282    }
283
284    pub fn base_edges(&self) -> u64 {
285        self.base.csr.num_edges()
286    }
287
288    /// Distinct source endpoints in the base (deltas excluded): an estimate for planning.
289    pub fn num_sources(&self) -> usize {
290        self.base.dense.len()
291    }
292
293    /// Appended edges held outside the base.
294    pub fn delta_edges(&self) -> u64 {
295        self.added_count
296    }
297
298    /// Removed edges recorded as tombstones.
299    pub fn tombstones(&self) -> u64 {
300        self.removed_count
301    }
302}
303
304// Summary only: DataFusion formats extension nodes with `{:?}` during physical
305// planning, so a derived Debug would print every edge on every query.
306impl fmt::Debug for Adjacency {
307    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
308        f.debug_struct("Adjacency")
309            .field("sources", &self.base.dense.len())
310            .field("edges", &self.num_edges())
311            .field("delta_edges", &self.added_count)
312            .field("tombstones", &self.removed_count)
313            .finish()
314    }
315}
316
317fn int64_column(batch: &RecordBatch, name: &str) -> Result<Int64Array> {
318    let (idx, _) = batch
319        .schema()
320        .fields()
321        .iter()
322        .enumerate()
323        .find(|(_, f)| f.name().eq_ignore_ascii_case(name))
324        .ok_or_else(|| exec_error(format!("Edge column '{}' not found", name)))?;
325    let array = cast(batch.column(idx), &DataType::Int64)
326        .map_err(|e| exec_error(format!("Edge column '{}' is not an integer: {}", name, e)))?;
327    Ok(array.as_primitive::<Int64Type>().clone())
328}
329
330/// Integer types whose values round-trip through `i64` without loss.
331pub fn is_supported_id_type(data_type: &DataType) -> bool {
332    matches!(
333        data_type,
334        DataType::Int8
335            | DataType::Int16
336            | DataType::Int32
337            | DataType::Int64
338            | DataType::UInt8
339            | DataType::UInt16
340            | DataType::UInt32
341    )
342}
343
344/// Edge rows added and removed between two versions of a relationship table, as
345/// `(source, target)` endpoint values in table orientation. Null endpoints are skipped.
346#[derive(Debug, Default, PartialEq)]
347pub struct EdgeChanges {
348    pub added: Vec<(i64, i64)>,
349    pub removed: Vec<(i64, i64)>,
350}
351
352impl EdgeChanges {
353    pub fn len(&self) -> usize {
354        self.added.len() + self.removed.len()
355    }
356
357    pub fn is_empty(&self) -> bool {
358        self.len() == 0
359    }
360}
361
362/// Diff two versions of a relationship table by fragment, reading only what changed.
363///
364/// Per fragment id: only in `new` -> its rows are added; only in `old` -> its rows
365/// (as of `old`) are removed; endpoint data files or overlays differ (rewrite,
366/// in-place column update) -> old rows removed and new rows added; only the
367/// deletion file differs -> rows newly deleted are read from `old` by physical
368/// offset. Fragments whose endpoint data and deletions are unchanged are skipped,
369/// so non-endpoint schema changes cost nothing.
370///
371/// Returns `None` when a diff is not meaningful (an endpoint column was dropped,
372/// re-created or retyped); the caller should rebuild. Errors (e.g. `old` files
373/// already deleted) should also lead to a rebuild.
374pub async fn edge_changes(
375    old: &Dataset,
376    new: &Dataset,
377    src: &str,
378    dst: &str,
379) -> Result<Option<EdgeChanges>> {
380    let endpoint_ids = |ds: &Dataset| -> Option<[(i32, DataType); 2]> {
381        let field = |name: &str| ds.schema().field(name).map(|f| (f.id, f.data_type()));
382        Some([field(src)?, field(dst)?])
383    };
384    let old_ids = endpoint_ids(old);
385    if old_ids.is_none() || old_ids != endpoint_ids(new) {
386        return Ok(None);
387    }
388    let fields: Vec<i32> = old_ids.unwrap().iter().map(|(id, _)| *id).collect();
389    let touches = |f: &[i32]| f.iter().any(|id| fields.contains(id));
390    // The parts of a fragment that determine its endpoint values.
391    let endpoint_data = |frag: &Fragment| {
392        let files: Vec<_> = frag.files.iter().filter(|d| touches(&d.fields)).collect();
393        let overlays: Vec<_> = frag
394            .overlays
395            .iter()
396            .filter(|o| touches(&o.data_file.fields))
397            .collect();
398        format!("{:?}{:?}", files, overlays)
399    };
400
401    let old_frags: HashMap<u64, Fragment> = old
402        .get_fragments()
403        .into_iter()
404        .map(|f| (f.id() as u64, f.metadata().clone()))
405        .collect();
406    let new_frags: HashMap<u64, Fragment> = new
407        .get_fragments()
408        .into_iter()
409        .map(|f| (f.id() as u64, f.metadata().clone()))
410        .collect();
411
412    let mut changes = EdgeChanges::default();
413    let mut removed_whole = Vec::new();
414    let mut added_whole = Vec::new();
415    for (id, old_frag) in &old_frags {
416        let Some(new_frag) = new_frags.get(id) else {
417            removed_whole.push(old_frag.clone());
418            continue;
419        };
420        if endpoint_data(old_frag) != endpoint_data(new_frag) {
421            removed_whole.push(old_frag.clone());
422            added_whole.push(new_frag.clone());
423        } else if old_frag.deletion_file != new_frag.deletion_file {
424            let deleted = |ds: &Dataset| {
425                let frag = ds.get_fragment(*id as usize);
426                async move {
427                    match frag {
428                        Some(frag) => frag.get_deletion_vector().await.map_err(lance_error),
429                        None => Ok(None),
430                    }
431                }
432            };
433            let (old_dv, new_dv) = (deleted(old).await?, deleted(new).await?);
434            let newly: HashSet<u32> = new_dv
435                .iter()
436                .flat_map(|dv| dv.to_sorted_iter().collect::<Vec<_>>())
437                .filter(|offset| !old_dv.as_ref().is_some_and(|dv| dv.contains(*offset)))
438                .collect();
439            if !newly.is_empty() {
440                let rows = scan_edges(old, vec![old_frag.clone()], src, dst, true).await?;
441                changes.removed.extend(
442                    rows.into_iter()
443                        .filter(|(_, addr)| newly.contains(&((addr & 0xFFFF_FFFF) as u32)))
444                        .map(|(edge, _)| edge),
445                );
446            }
447        }
448    }
449    for (id, new_frag) in &new_frags {
450        if !old_frags.contains_key(id) {
451            added_whole.push(new_frag.clone());
452        }
453    }
454    if !removed_whole.is_empty() {
455        let rows = scan_edges(old, removed_whole, src, dst, false).await?;
456        changes
457            .removed
458            .extend(rows.into_iter().map(|(edge, _)| edge));
459    }
460    if !added_whole.is_empty() {
461        let rows = scan_edges(new, added_whole, src, dst, false).await?;
462        changes.added.extend(rows.into_iter().map(|(edge, _)| edge));
463    }
464    Ok(Some(changes))
465}
466
467/// Live `(src, dst)` rows of `fragments` in `ds`, with each row's physical address
468/// when `with_address` is set (otherwise 0). Null endpoints are skipped.
469async fn scan_edges(
470    ds: &Dataset,
471    fragments: Vec<Fragment>,
472    src: &str,
473    dst: &str,
474    with_address: bool,
475) -> Result<Vec<((i64, i64), u64)>> {
476    let mut scanner = ds.scan();
477    scanner.with_fragments(fragments);
478    scanner.project(&[src, dst]).map_err(lance_error)?;
479    if with_address {
480        scanner.with_row_address();
481    }
482    let mut stream = scanner.try_into_stream().await.map_err(lance_error)?;
483    let mut out = Vec::new();
484    while let Some(batch) = stream.next().await {
485        let batch = batch.map_err(lance_error)?;
486        let (s, d) = (int64_column(&batch, src)?, int64_column(&batch, dst)?);
487        let addrs = with_address.then(|| {
488            batch
489                .column_by_name(ROW_ADDR)
490                .map(|c| c.as_primitive::<arrow::datatypes::UInt64Type>().clone())
491        });
492        let addrs = match addrs {
493            Some(None) => return Err(exec_error("Edge scan returned no row addresses".into())),
494            Some(Some(a)) => Some(a),
495            None => None,
496        };
497        for i in 0..batch.num_rows() {
498            if s.is_valid(i) && d.is_valid(i) {
499                let addr = addrs.as_ref().map_or(0, |a| a.value(i));
500                out.push(((s.value(i), d.value(i)), addr));
501            }
502        }
503    }
504    Ok(out)
505}
506
507fn lance_error(e: lance::Error) -> GraphError {
508    exec_error(format!("Lance: {}", e))
509}
510
511/// Rows of one node table, addressable by node id, for the fused target lookup.
512pub struct NodeRows {
513    /// Node id -> Lance row id, or row index into the in-memory batch.
514    positions: FastMap<i64, u64>,
515    data: NodeData,
516    schema: SchemaRef,
517    /// Index of the id column in `schema`; its values are reconstructed, never fetched.
518    id_index: usize,
519    #[cfg(test)]
520    fetched_cells: std::sync::atomic::AtomicUsize,
521}
522
523enum NodeData {
524    /// Pinned dataset; positions are Lance row ids (row addresses unless the dataset
525    /// uses stable row ids), which stay valid for this version.
526    Lance(Arc<Dataset>),
527    Memory(RecordBatch),
528}
529
530impl NodeRows {
531    /// Index a Lance node table by scanning only its id column with row ids.
532    ///
533    /// Returns `None` when ids are not integers or are not unique: a duplicate id
534    /// matches several rows in the join, which a single position cannot reproduce.
535    pub async fn from_lance(dataset: Arc<Dataset>, id_column: &str) -> Result<Option<Self>> {
536        let schema: SchemaRef = Arc::new(Schema::from(dataset.schema()));
537        let Some((id_index, id_name)) = supported_id_column(&schema, id_column) else {
538            return Ok(None);
539        };
540        let lance_err = |e: lance::Error| exec_error(format!("Failed to scan node ids: {}", e));
541        let mut scanner = dataset.scan();
542        scanner.project(&[id_name.as_str()]).map_err(lance_err)?;
543        scanner.with_row_id();
544        let mut stream = scanner.try_into_stream().await.map_err(lance_err)?;
545        let mut positions = FastMap::default();
546        while let Some(batch) = stream.next().await {
547            let batch = batch.map_err(lance_err)?;
548            let ids = int64_column(&batch, &id_name)?;
549            let row_ids = batch
550                .column_by_name(lance::dataset::ROW_ID)
551                .ok_or_else(|| exec_error("Node scan returned no row ids".to_string()))?
552                .as_primitive::<arrow::datatypes::UInt64Type>()
553                .clone();
554            if !insert_unique(&mut positions, &ids, |i| row_ids.value(i)) {
555                return Ok(None);
556            }
557        }
558        Ok(Some(Self {
559            positions,
560            data: NodeData::Lance(dataset),
561            schema,
562            id_index,
563            #[cfg(test)]
564            fetched_cells: std::sync::atomic::AtomicUsize::new(0),
565        }))
566    }
567
568    /// Index an in-memory node table. Same `None` rules as [`Self::from_lance`].
569    pub fn from_batch(batch: RecordBatch, id_column: &str) -> Result<Option<Self>> {
570        let Some((id_index, id_name)) = supported_id_column(&batch.schema(), id_column) else {
571            return Ok(None);
572        };
573        let ids = int64_column(&batch, &id_name)?;
574        let mut positions = FastMap::default();
575        if !insert_unique(&mut positions, &ids, |i| i as u64) {
576            return Ok(None);
577        }
578        Ok(Some(Self {
579            positions,
580            schema: batch.schema(),
581            data: NodeData::Memory(batch),
582            id_index,
583            #[cfg(test)]
584            fetched_cells: std::sync::atomic::AtomicUsize::new(0),
585        }))
586    }
587
588    pub fn num_rows(&self) -> usize {
589        self.positions.len()
590    }
591
592    pub(crate) fn position(&self, id: i64) -> Option<u64> {
593        self.positions.get(&id).copied()
594    }
595
596    /// Fetch `columns` (ascending schema indices) of the rows at `positions`, in
597    /// row order with duplicates kept. Returns one array per requested column.
598    async fn take(&self, positions: &[u64], columns: &[usize]) -> Result<Vec<ArrayRef>> {
599        if columns.is_empty() {
600            return Ok(vec![]);
601        }
602        if positions.is_empty() {
603            return Ok(columns
604                .iter()
605                .map(|&c| new_empty_array(self.schema.field(c).data_type()))
606                .collect());
607        }
608        #[cfg(test)]
609        self.fetched_cells.fetch_add(
610            positions.len() * columns.len(),
611            std::sync::atomic::Ordering::Relaxed,
612        );
613        match &self.data {
614            NodeData::Lance(dataset) => {
615                let names = columns.iter().map(|&c| self.schema.field(c).name());
616                // Lance returns projected columns in schema order, which `columns` follows.
617                let projection = ProjectionRequest::from_columns(names, dataset.schema());
618                let batch = dataset
619                    .take_rows(positions, projection)
620                    .await
621                    .map_err(|e| exec_error(format!("Failed to take target rows: {}", e)))?;
622                Ok(batch.columns().to_vec())
623            }
624            NodeData::Memory(batch) => {
625                let indices = UInt64Array::from(positions.to_vec());
626                columns
627                    .iter()
628                    .map(|&c| take(batch.column(c).as_ref(), &indices, None))
629                    .collect::<std::result::Result<_, _>>()
630                    .map_err(|e| exec_error(format!("Failed to take target rows: {}", e)))
631            }
632        }
633    }
634}
635
636// Summary only, for the same reason as `Adjacency`.
637impl fmt::Debug for NodeRows {
638    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
639        f.debug_struct("NodeRows")
640            .field("rows", &self.positions.len())
641            .finish()
642    }
643}
644
645/// Index and actual name of the id column, if it exists (case-insensitively) with a supported type.
646fn supported_id_column(schema: &Schema, id_column: &str) -> Option<(usize, String)> {
647    schema
648        .fields()
649        .iter()
650        .enumerate()
651        .find(|(_, f)| f.name().eq_ignore_ascii_case(id_column))
652        .filter(|(_, f)| is_supported_id_type(f.data_type()))
653        .map(|(i, f)| (i, f.name().clone()))
654}
655
656/// Insert non-null ids; false if any id repeats.
657fn insert_unique(
658    positions: &mut FastMap<i64, u64>,
659    ids: &Int64Array,
660    position_of: impl Fn(usize) -> u64,
661) -> bool {
662    for i in 0..ids.len() {
663        if ids.is_valid(i) && positions.insert(ids.value(i), position_of(i)).is_some() {
664            return false;
665        }
666    }
667    true
668}
669
670/// A fused target lookup: the target variable's columns, fetched from `rows`.
671#[derive(Clone)]
672pub struct TargetLookup {
673    pub variable: String,
674    pub rows: Arc<NodeRows>,
675    /// Output fields, named as the target scan would name them (`{variable}__{column}`).
676    fields: Vec<Field>,
677    /// For each output field, its column index in `rows` (ascending).
678    columns: Vec<usize>,
679}
680
681/// A moved target filter compiled for one query: evaluated per batch on just the
682/// columns it reads, for the candidate target rows of that batch.
683#[derive(Debug, Clone)]
684struct CompiledTargetFilter {
685    predicate: Arc<dyn PhysicalExpr>,
686    /// Target-table column indices the predicate reads, ascending (`schema`'s order).
687    columns: Vec<usize>,
688    schema: SchemaRef,
689}
690
691// Summary only, like `Adjacency`: DataFusion formats extension nodes with `{:?}`.
692impl fmt::Debug for TargetLookup {
693    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
694        f.debug_struct("TargetLookup")
695            .field("variable", &self.variable)
696            .field("columns", &self.describe_columns())
697            .finish()
698    }
699}
700
701impl TargetLookup {
702    /// Qualified output name (`b__name`) -> the node table's own column name, for
703    /// every column of the table, including ones pruned from the output.
704    fn raw_columns(&self) -> HashMap<String, String> {
705        self.rows
706            .schema
707            .fields()
708            .iter()
709            .map(|f| (qualify_column(&self.variable, f.name()), f.name().clone()))
710            .collect()
711    }
712
713    /// Compile `filter` (an expression over qualified target columns such as
714    /// `b__name`) against the target table's own columns, for per-batch evaluation.
715    fn compile_filter(
716        &self,
717        filter: &Expr,
718        state: &SessionState,
719    ) -> datafusion::common::Result<CompiledTargetFilter> {
720        let raw = self.raw_columns();
721        let filter = filter
722            .clone()
723            .transform(|e| match &e {
724                Expr::Column(c) => match raw.get(&c.name) {
725                    Some(name) => Ok(Transformed::yes(Expr::Column(Column::from_name(name)))),
726                    None => Ok(Transformed::no(e)),
727                },
728                _ => Ok(Transformed::no(e)),
729            })?
730            .data;
731        let mut columns: Vec<usize> = filter
732            .column_refs()
733            .iter()
734            .map(|c| self.rows.schema.index_of(&c.name))
735            .collect::<std::result::Result<_, _>>()?;
736        columns.sort_unstable();
737        columns.dedup();
738        let schema = Arc::new(self.rows.schema.project(&columns)?);
739        let predicate =
740            state.create_physical_expr(filter, &DFSchema::try_from(schema.as_ref().clone())?)?;
741        Ok(CompiledTargetFilter {
742            predicate,
743            columns,
744            schema,
745        })
746    }
747
748    /// `[b__id*, b__name]`: output columns, `*` marking the id rebuilt from neighbour values.
749    fn describe_columns(&self) -> String {
750        let names: Vec<String> = self
751            .columns
752            .iter()
753            .zip(&self.fields)
754            .map(|(&c, f)| {
755                let mark = if c == self.rows.id_index { "*" } else { "" };
756                format!("{}{}", f.name(), mark)
757            })
758            .collect();
759        format!("[{}]", names.join(", "))
760    }
761
762    /// All target columns; [`PruneCsrTargetColumns`] later drops unused ones.
763    pub fn new(variable: String, rows: Arc<NodeRows>, fields: Vec<Field>) -> Self {
764        let columns = (0..fields.len()).collect();
765        Self {
766            variable,
767            rows,
768            fields,
769            columns,
770        }
771    }
772}
773
774/// Adjacencies and node indexes available to the planner.
775///
776/// Adjacencies are keyed by lowercase relationship type and direction; node indexes
777/// by lowercase node label.
778#[derive(Debug, Clone, Default)]
779pub struct AdjacencyCatalog {
780    entries: HashMap<(String, bool), Arc<Adjacency>>,
781    nodes: HashMap<String, Arc<NodeRows>>,
782}
783
784impl AdjacencyCatalog {
785    pub fn insert(&mut self, rel_type: &str, outgoing: bool, adjacency: Arc<Adjacency>) {
786        self.entries
787            .insert((rel_type.to_lowercase(), outgoing), adjacency);
788    }
789
790    pub fn get(&self, rel_type: &str, outgoing: bool) -> Option<&Arc<Adjacency>> {
791        self.entries.get(&(rel_type.to_lowercase(), outgoing))
792    }
793
794    pub fn insert_nodes(&mut self, label: &str, rows: Arc<NodeRows>) {
795        self.nodes.insert(label.to_lowercase(), rows);
796    }
797
798    pub fn nodes(&self, label: &str) -> Option<&Arc<NodeRows>> {
799        self.nodes.get(&label.to_lowercase())
800    }
801}
802
803/// The neighbours one expansion follows: one adjacency for a directed pattern, or
804/// the outgoing and incoming adjacencies of the same relationship type for an
805/// undirected one. Undirected expansion visits outgoing neighbours, then incoming
806/// ones, skipping self-loops the second time so each edge matches once from each
807/// end and a self-loop once: the same rows as the join path's reversed-edge scan.
808#[derive(Debug, Clone)]
809pub struct Neighbors {
810    forward: Arc<Adjacency>,
811    reverse: Option<Arc<Adjacency>>,
812}
813
814/// Occurrence offsets are sufficient to pause even a high-degree source row.
815#[derive(Clone, Copy, Default)]
816pub(crate) struct NeighborPosition {
817    forward: usize,
818    reverse: usize,
819}
820
821impl Neighbors {
822    pub fn directed(adjacency: Arc<Adjacency>) -> Self {
823        Self {
824            forward: adjacency,
825            reverse: None,
826        }
827    }
828
829    pub fn undirected(outgoing: Arc<Adjacency>, incoming: Arc<Adjacency>) -> Self {
830        Self {
831            forward: outgoing,
832            reverse: Some(incoming),
833        }
834    }
835
836    pub fn for_each_neighbor(&self, key: i64, f: impl FnMut(u64)) {
837        self.neighbors_from(key, &mut NeighborPosition::default())
838            .for_each(f);
839    }
840
841    #[inline(always)]
842    pub(crate) fn neighbors_from<'a>(
843        &'a self,
844        key: i64,
845        position: &'a mut NeighborPosition,
846    ) -> impl Iterator<Item = u64> + 'a {
847        self.candidates_from(key, position).flatten()
848    }
849
850    /// Borrow each adjacency once while exposing tombstones and suppressed self-loops as work.
851    #[inline(always)]
852    pub(crate) fn candidates_from<'a>(
853        &'a self,
854        key: i64,
855        position: &'a mut NeighborPosition,
856    ) -> impl Iterator<Item = Option<u64>> + 'a {
857        let forward = self.forward.candidates_from(key, &mut position.forward);
858        let reverse = self
859            .reverse
860            .as_ref()
861            .map(|r| r.candidates_from(key, &mut position.reverse));
862        forward.chain(
863            reverse
864                .into_iter()
865                .flatten()
866                .map(move |candidate| candidate.filter(|&n| n as i64 != key)),
867        )
868    }
869
870    /// Edges visited over all keys (self-loops count once per direction in this estimate).
871    pub fn num_edges(&self) -> u64 {
872        self.forward.num_edges() + self.reverse.as_ref().map_or(0, |r| r.num_edges())
873    }
874
875    fn is_undirected(&self) -> bool {
876        self.reverse.is_some()
877    }
878
879    /// Mean neighbours per source key over the base CSRs: a planning estimate.
880    fn mean_degree(&self) -> f64 {
881        let degree = |a: &Adjacency| a.base_edges() as f64 / a.num_sources().max(1) as f64;
882        degree(&self.forward) + self.reverse.as_deref().map_or(0.0, degree)
883    }
884
885    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
886        Arc::ptr_eq(&self.forward, &other.forward)
887            && match (&self.reverse, &other.reverse) {
888                (Some(a), Some(b)) => Arc::ptr_eq(a, b),
889                (None, None) => true,
890                _ => false,
891            }
892    }
893}
894
895/// Logical node: append `(key_output, neighbor_output)` for every neighbour of `key_column`.
896#[derive(Debug, Clone)]
897pub struct CsrExpandNode {
898    input: LogicalPlan,
899    /// Input column holding the source node id.
900    key_column: String,
901    /// Output names/types of the relationship endpoint columns, as the relationship scan names them.
902    key_output: Field,
903    neighbor_output: Field,
904    adjacency: Neighbors,
905    /// When set, the target node's columns are fetched here instead of by a join.
906    target: Option<TargetLookup>,
907    /// WHERE conjuncts over target columns only, moved here by [`PushCsrTargetFilters`]:
908    /// neighbours that fail it are dropped before any take.
909    target_filter: Option<Expr>,
910    /// Closing edge: input column holding the already-bound target id. Only neighbours
911    /// equal to it are kept, replacing a filter after a full expansion.
912    closing_column: Option<String>,
913    traversal: Option<Arc<crate::csr_traversal::Traversal>>,
914    schema: DFSchemaRef,
915}
916
917impl CsrExpandNode {
918    pub fn try_new(
919        input: LogicalPlan,
920        key_column: String,
921        key_output: Field,
922        neighbor_output: Field,
923        adjacency: Neighbors,
924        target: Option<TargetLookup>,
925    ) -> Result<Self> {
926        let mut added_fields = vec![key_output.clone(), neighbor_output.clone()];
927        added_fields.extend(target.iter().flat_map(|t| t.fields.iter().cloned()));
928        let added = DFSchema::try_from(Schema::new(added_fields))
929            .map_err(|e| plan_error(format!("CsrExpand schema: {}", e)))?;
930        let schema = input
931            .schema()
932            .join(&added)
933            .map_err(|e| plan_error(format!("CsrExpand schema: {}", e)))?;
934        Ok(Self {
935            input,
936            key_column,
937            key_output,
938            neighbor_output,
939            adjacency,
940            target,
941            target_filter: None,
942            closing_column: None,
943            traversal: None,
944            schema: Arc::new(schema),
945        })
946    }
947
948    /// Keep only neighbours equal to the bound target id in `column` (a closing edge).
949    pub fn with_closing_column(mut self, column: String) -> Self {
950        self.closing_column = Some(column);
951        self
952    }
953
954    pub(crate) fn with_traversal(mut self, traversal: crate::csr_traversal::Traversal) -> Self {
955        self.traversal = Some(Arc::new(traversal));
956        self
957    }
958
959    /// The same node over `input` with `target`, keeping the moved filter and closing column.
960    fn rebuilt(&self, input: LogicalPlan, target: Option<TargetLookup>) -> Result<Self> {
961        let mut node = Self::try_new(
962            input,
963            self.key_column.clone(),
964            self.key_output.clone(),
965            self.neighbor_output.clone(),
966            self.adjacency.clone(),
967            target,
968        )?;
969        node.target_filter = self.target_filter.clone();
970        node.closing_column = self.closing_column.clone();
971        node.traversal = self.traversal.clone();
972        Ok(node)
973    }
974
975    /// This node with only the target columns named in `used`, or `None` if nothing changes.
976    fn with_target_columns(&self, used: &HashSet<String>) -> Option<Self> {
977        let target = self.target.as_ref()?;
978        let (columns, fields): (Vec<usize>, Vec<Field>) = target
979            .columns
980            .iter()
981            .zip(&target.fields)
982            .filter(|(_, f)| used.contains(f.name()))
983            .map(|(&c, f)| (c, f.clone()))
984            .unzip();
985        if fields.len() == target.fields.len() {
986            return None;
987        }
988        let target = TargetLookup {
989            columns,
990            fields,
991            ..target.clone()
992        };
993        self.rebuilt(self.input.clone(), Some(target)).ok()
994    }
995
996    /// Names of the columns this node adds (endpoints, then any target columns).
997    fn added_columns(&self) -> impl Iterator<Item = &String> {
998        [self.key_output.name(), self.neighbor_output.name()]
999            .into_iter()
1000            .chain(
1001                self.target
1002                    .iter()
1003                    .flat_map(|t| t.fields.iter().map(|f| f.name())),
1004            )
1005    }
1006}
1007
1008// Logical nodes must be comparable; the adjacency compares by identity.
1009impl PartialEq for CsrExpandNode {
1010    fn eq(&self, other: &Self) -> bool {
1011        self.input == other.input
1012            && self.key_column == other.key_column
1013            && self.key_output == other.key_output
1014            && self.neighbor_output == other.neighbor_output
1015            && self.adjacency.ptr_eq(&other.adjacency)
1016            && match (&self.target, &other.target) {
1017                (Some(a), Some(b)) => {
1018                    a.variable == b.variable
1019                        && a.columns == b.columns
1020                        && Arc::ptr_eq(&a.rows, &b.rows)
1021                }
1022                (None, None) => true,
1023                _ => false,
1024            }
1025            && self.target_filter == other.target_filter
1026            && self.closing_column == other.closing_column
1027            && match (&self.traversal, &other.traversal) {
1028                (Some(a), Some(b)) => Arc::ptr_eq(a, b),
1029                (None, None) => true,
1030                _ => false,
1031            }
1032    }
1033}
1034impl Eq for CsrExpandNode {}
1035impl Hash for CsrExpandNode {
1036    fn hash<H: Hasher>(&self, state: &mut H) {
1037        self.input.hash(state);
1038        self.key_column.hash(state);
1039        self.key_output.name().hash(state);
1040        self.neighbor_output.name().hash(state);
1041        self.target.as_ref().map(|t| &t.variable).hash(state);
1042        self.target_filter.hash(state);
1043        self.closing_column.hash(state);
1044    }
1045}
1046impl PartialOrd for CsrExpandNode {
1047    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1048        (
1049            &self.key_column,
1050            self.key_output.name(),
1051            self.neighbor_output.name(),
1052        )
1053            .partial_cmp(&(
1054                &other.key_column,
1055                other.key_output.name(),
1056                other.neighbor_output.name(),
1057            ))
1058    }
1059}
1060
1061impl UserDefinedLogicalNodeCore for CsrExpandNode {
1062    fn name(&self) -> &str {
1063        "CsrExpand"
1064    }
1065
1066    fn inputs(&self) -> Vec<&LogicalPlan> {
1067        vec![&self.input]
1068    }
1069
1070    fn schema(&self) -> &DFSchemaRef {
1071        &self.schema
1072    }
1073
1074    fn expressions(&self) -> Vec<Expr> {
1075        vec![]
1076    }
1077
1078    /// Filters on input columns may move below the expansion; filters on the
1079    /// columns this node creates must stay above it.
1080    fn prevent_predicate_push_down_columns(&self) -> HashSet<String> {
1081        self.added_columns().cloned().collect()
1082    }
1083
1084    /// Projection pushdown: the input only needs the requested input columns plus
1085    /// the key; the two appended columns are computed here.
1086    fn necessary_children_exprs(&self, output_columns: &[usize]) -> Option<Vec<Vec<usize>>> {
1087        let input_width = self.input.schema().fields().len();
1088        let key = self
1089            .input
1090            .schema()
1091            .index_of_column_by_name(None, &self.key_column)?;
1092        let closing = self
1093            .closing_column
1094            .as_deref()
1095            .map(|c| self.input.schema().index_of_column_by_name(None, c));
1096        let closing = match closing {
1097            Some(None) => return None,
1098            Some(Some(i)) => Some(i),
1099            None => None,
1100        };
1101        let mut needed: Vec<usize> = output_columns
1102            .iter()
1103            .copied()
1104            .filter(|&i| i < input_width)
1105            .chain(std::iter::once(key))
1106            .chain(closing)
1107            .collect();
1108        needed.sort_unstable();
1109        needed.dedup();
1110        Some(vec![needed])
1111    }
1112
1113    fn fmt_for_explain(&self, f: &mut fmt::Formatter) -> fmt::Result {
1114        write!(
1115            f,
1116            "CsrExpand: key={} -> [{}, {}], edges={}",
1117            self.key_column,
1118            self.key_output.name(),
1119            self.neighbor_output.name(),
1120            self.adjacency.num_edges()
1121        )?;
1122        if self.adjacency.is_undirected() {
1123            write!(f, ", undirected")?;
1124        }
1125        if let Some(target) = &self.target {
1126            write!(f, ", take={}{}", target.variable, target.describe_columns())?;
1127        }
1128        if let Some(filter) = &self.target_filter {
1129            write!(f, ", filter={}", filter)?;
1130        }
1131        if let Some(column) = &self.closing_column {
1132            write!(f, ", match={}", column)?;
1133        }
1134        if let Some(walk) = &self.traversal {
1135            write!(f, ", iterative hops={}..{}", walk.min_hops, walk.max_hops)?;
1136        }
1137        Ok(())
1138    }
1139
1140    fn with_exprs_and_inputs(
1141        &self,
1142        _exprs: Vec<Expr>,
1143        inputs: Vec<LogicalPlan>,
1144    ) -> datafusion::common::Result<Self> {
1145        let input = inputs.into_iter().next().expect("CsrExpand has one input");
1146        self.rebuilt(input, self.target.clone())
1147            .map_err(|e| datafusion::common::DataFusionError::Plan(e.to_string()))
1148    }
1149}
1150
1151/// Physical operator for [`CsrExpandNode`]; expands input rows in bounded chunks.
1152#[derive(Debug)]
1153pub struct CsrExpandExec {
1154    input: Arc<dyn ExecutionPlan>,
1155    key_column: String,
1156    adjacency: Neighbors,
1157    target: Option<TargetLookup>,
1158    /// The moved target filter, if any.
1159    target_filter: Option<CompiledTargetFilter>,
1160    closing_column: Option<String>,
1161    traversal: Option<Arc<crate::csr_traversal::Traversal>>,
1162    schema: SchemaRef,
1163    properties: Arc<PlanProperties>,
1164}
1165
1166impl CsrExpandExec {
1167    #[allow(clippy::too_many_arguments)]
1168    fn new(
1169        input: Arc<dyn ExecutionPlan>,
1170        key_column: String,
1171        adjacency: Neighbors,
1172        target: Option<TargetLookup>,
1173        target_filter: Option<CompiledTargetFilter>,
1174        closing_column: Option<String>,
1175        traversal: Option<Arc<crate::csr_traversal::Traversal>>,
1176        schema: SchemaRef,
1177    ) -> Self {
1178        let properties = Arc::new(PlanProperties::new(
1179            EquivalenceProperties::new(schema.clone()),
1180            Partitioning::UnknownPartitioning(input.output_partitioning().partition_count()),
1181            EmissionType::Incremental,
1182            Boundedness::Bounded,
1183        ));
1184        Self {
1185            input,
1186            key_column,
1187            adjacency,
1188            target,
1189            target_filter,
1190            closing_column,
1191            traversal,
1192            schema,
1193            properties,
1194        }
1195    }
1196}
1197
1198/// One input batch and its current row/occurrence, independent of total fan-out.
1199struct BatchExpansion {
1200    batch: RecordBatch,
1201    keys: Int64Array,
1202    bound: Option<Int64Array>,
1203    row: usize,
1204    position: NeighborPosition,
1205    walk: Option<crate::csr_traversal::TraversalCursor>,
1206}
1207
1208impl BatchExpansion {
1209    fn try_new(
1210        batch: RecordBatch,
1211        key_index: usize,
1212        closing_index: Option<usize>,
1213    ) -> datafusion::common::Result<Self> {
1214        let ints = |i| {
1215            cast(batch.column(i), &DataType::Int64).map(|a| a.as_primitive::<Int64Type>().clone())
1216        };
1217        let keys = ints(key_index)?;
1218        let bound = closing_index.map(ints).transpose()?;
1219        Ok(Self {
1220            batch,
1221            keys,
1222            bound,
1223            row: 0,
1224            position: NeighborPosition::default(),
1225            walk: None,
1226        })
1227    }
1228
1229    /// Collect only the next chunk; target properties are fetched after this returns.
1230    async fn next_chunk(
1231        &mut self,
1232        adjacency: &Neighbors,
1233        target: Option<&TargetLookup>,
1234        batch_size: usize,
1235    ) -> ExpandedRows {
1236        let mut chunk = ExpandedRows::default();
1237        loop {
1238            if self.fill_chunk(adjacency, target, batch_size, &mut chunk) {
1239                cooperative_yield().await;
1240            }
1241            if self.row == self.keys.len() || chunk.rows.len() == batch_size.max(1) {
1242                return chunk;
1243            }
1244        }
1245    }
1246
1247    /// Keep coroutine state outside the scan while counting deleted occurrences as work.
1248    fn fill_chunk(
1249        &mut self,
1250        adjacency: &Neighbors,
1251        target: Option<&TargetLookup>,
1252        batch_size: usize,
1253        chunk: &mut ExpandedRows,
1254    ) -> bool {
1255        let mut position = self.position;
1256        let mut row = self.row;
1257        let mut work = 0_usize;
1258        'rows: while row < self.keys.len() {
1259            let i = row;
1260            let previous_work = work;
1261            if self.keys.is_valid(i) && self.bound.as_ref().is_none_or(|b| b.is_valid(i)) {
1262                let key = self.keys.value(i);
1263                let required = self.bound.as_ref().map(|b| b.value(i));
1264                for candidate in adjacency
1265                    .candidates_from(key, &mut position)
1266                    .take(1024 - work)
1267                {
1268                    work += 1;
1269                    let Some(neighbor) = candidate else {
1270                        continue;
1271                    };
1272                    let neighbor = neighbor as i64;
1273                    if required.is_some_and(|r| r != neighbor) {
1274                        continue;
1275                    }
1276                    if let Some(target) = target {
1277                        let Some(target_row) = target.rows.position(neighbor) else {
1278                            continue;
1279                        };
1280                        chunk.target_positions.push(target_row);
1281                    }
1282                    chunk.rows.push(i as u32);
1283                    chunk.key_values.push(key);
1284                    chunk.neighbor_values.push(neighbor);
1285                    if chunk.rows.len() == batch_size.max(1) {
1286                        break 'rows;
1287                    }
1288                }
1289                if work == 1024 {
1290                    break;
1291                }
1292            }
1293            if work == previous_work {
1294                work += 1;
1295            }
1296            row += 1;
1297            position = NeighborPosition::default();
1298            if work == 1024 {
1299                break;
1300            }
1301        }
1302        self.row = row;
1303        self.position = position;
1304        work == 1024
1305    }
1306
1307    async fn next_path_chunk(
1308        &mut self,
1309        graph: &crate::csr_traversal::Traversal,
1310        target: Option<&TargetLookup>,
1311        batch_size: usize,
1312    ) -> ExpandedRows {
1313        let mut chunk = ExpandedRows::default();
1314        let mut source_work = 0_usize;
1315        while self.row < self.keys.len() {
1316            source_work += 1;
1317            if source_work.is_multiple_of(1024) {
1318                cooperative_yield().await;
1319            }
1320            let i = self.row;
1321            if self.keys.is_valid(i) && self.bound.as_ref().is_none_or(|b| b.is_valid(i)) {
1322                let key = self.keys.value(i);
1323                let walk = self
1324                    .walk
1325                    .get_or_insert_with(|| crate::csr_traversal::TraversalCursor::new(graph, key));
1326                let mut candidates = 0usize;
1327                while let Some(neighbor) = walk.next(graph).await {
1328                    candidates += 1;
1329                    if candidates.is_multiple_of(1024) {
1330                        cooperative_yield().await;
1331                    }
1332                    if self.bound.as_ref().is_some_and(|b| b.value(i) != neighbor) {
1333                        continue;
1334                    }
1335                    if let Some(target) = target {
1336                        let Some(position) = target.rows.position(neighbor) else {
1337                            continue;
1338                        };
1339                        chunk.target_positions.push(position);
1340                    }
1341                    chunk.rows.push(i as u32);
1342                    chunk.key_values.push(key);
1343                    chunk.neighbor_values.push(neighbor);
1344                    if chunk.rows.len() == batch_size.max(1) {
1345                        return chunk;
1346                    }
1347                }
1348            }
1349            self.row += 1;
1350            self.walk = None;
1351        }
1352        chunk
1353    }
1354}
1355
1356/// Expanded rows of one input batch: input row index, both endpoints, and the
1357/// target row position when a lookup is fused in.
1358#[derive(Default)]
1359struct ExpandedRows {
1360    rows: Vec<u32>,
1361    key_values: Vec<i64>,
1362    neighbor_values: Vec<i64>,
1363    target_positions: Vec<u64>,
1364}
1365
1366/// Apply the moved target filter to `expanded`, then build the output batch.
1367async fn materialize(
1368    batch: &RecordBatch,
1369    expanded: ExpandedRows,
1370    target: Option<&TargetLookup>,
1371    target_filter: Option<&CompiledTargetFilter>,
1372    schema: &SchemaRef,
1373) -> datafusion::common::Result<RecordBatch> {
1374    let ExpandedRows {
1375        mut rows,
1376        mut key_values,
1377        mut neighbor_values,
1378        mut target_positions,
1379    } = expanded;
1380    let mut reused = Vec::new();
1381    if let (Some(target), Some(filter)) = (target, target_filter) {
1382        // Take only the filter's columns, evaluate, and keep passing rows before
1383        // taking anything else. Null counts as false, as in a WHERE clause.
1384        let fetch: Vec<_> = filter
1385            .columns
1386            .iter()
1387            .copied()
1388            .filter(|&c| c != target.rows.id_index)
1389            .collect();
1390        let mut taken = target
1391            .rows
1392            .take(&target_positions, &fetch)
1393            .await
1394            .map_err(|e| datafusion::common::DataFusionError::External(Box::new(e)))?
1395            .into_iter();
1396        let arrays = filter
1397            .columns
1398            .iter()
1399            .enumerate()
1400            .map(|(i, &c)| {
1401                if c == target.rows.id_index {
1402                    cast(
1403                        &Int64Array::from(neighbor_values.clone()),
1404                        filter.schema.field(i).data_type(),
1405                    )
1406                } else {
1407                    Ok(taken
1408                        .next()
1409                        .expect("one fetched array per non-id filter column"))
1410                }
1411            })
1412            .collect::<std::result::Result<Vec<_>, _>>()?;
1413        let candidates = RecordBatch::try_new(filter.schema.clone(), arrays)?;
1414        let mask = filter
1415            .predicate
1416            .evaluate(&candidates)?
1417            .into_array(candidates.num_rows())?;
1418        let mask = mask.as_boolean();
1419        let keep: Vec<bool> = (0..mask.len())
1420            .map(|i| mask.is_valid(i) && mask.value(i))
1421            .collect();
1422        for (i, &column) in filter.columns.iter().enumerate() {
1423            if column != target.rows.id_index && target.columns.contains(&column) {
1424                reused.push((column, filter_array(candidates.column(i), mask)?));
1425            }
1426        }
1427        retain_by_mask(&mut rows, &keep);
1428        retain_by_mask(&mut key_values, &keep);
1429        retain_by_mask(&mut neighbor_values, &keep);
1430        retain_by_mask(&mut target_positions, &keep);
1431    }
1432    let rows = UInt32Array::from(rows);
1433    let mut columns: Vec<ArrayRef> = batch
1434        .columns()
1435        .iter()
1436        .map(|c| take(c.as_ref(), &rows, None))
1437        .collect::<std::result::Result<_, _>>()?;
1438    let width = batch.num_columns();
1439    let neighbor_values = Int64Array::from(neighbor_values);
1440    columns.push(cast(
1441        &Int64Array::from(key_values),
1442        schema.field(width).data_type(),
1443    )?);
1444    columns.push(cast(&neighbor_values, schema.field(width + 1).data_type())?);
1445    if let Some(target) = target {
1446        let id_index = target.rows.id_index;
1447        let fetch: Vec<usize> = target
1448            .columns
1449            .iter()
1450            .copied()
1451            .filter(|&c| c != id_index && !reused.iter().any(|(column, _)| *column == c))
1452            .collect();
1453        let mut fetched = target
1454            .rows
1455            .take(&target_positions, &fetch)
1456            .await
1457            .map_err(|e| datafusion::common::DataFusionError::External(Box::new(e)))?
1458            .into_iter();
1459        for (&c, field) in target.columns.iter().zip(&target.fields) {
1460            // The target id equals the neighbour value, so it is never fetched.
1461            columns.push(if c == id_index {
1462                cast(&neighbor_values, field.data_type())?
1463            } else if let Some((_, array)) = reused.iter().find(|(column, _)| *column == c) {
1464                array.clone()
1465            } else {
1466                fetched.next().expect("one fetched array per non-id column")
1467            });
1468        }
1469    }
1470    Ok(RecordBatch::try_new(schema.clone(), columns)?)
1471}
1472
1473/// Keep the elements of `values` whose position in `keep` is true.
1474fn retain_by_mask<T>(values: &mut Vec<T>, keep: &[bool]) {
1475    let mut keep = keep.iter();
1476    values.retain(|_| *keep.next().expect("one mask entry per value"));
1477}
1478
1479impl DisplayAs for CsrExpandExec {
1480    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
1481        write!(
1482            f,
1483            "CsrExpandExec: key={}, edges={}",
1484            self.key_column,
1485            self.adjacency.num_edges()
1486        )?;
1487        if self.adjacency.is_undirected() {
1488            write!(f, ", undirected")?;
1489        }
1490        if let Some(target) = &self.target {
1491            write!(f, ", take={}{}", target.variable, target.describe_columns())?;
1492        }
1493        if let Some(filter) = &self.target_filter {
1494            write!(f, ", filter={}", filter.predicate)?;
1495        }
1496        if let Some(column) = &self.closing_column {
1497            write!(f, ", match={}", column)?;
1498        }
1499        if let Some(walk) = &self.traversal {
1500            write!(f, ", iterative hops={}..{}", walk.min_hops, walk.max_hops)?;
1501        }
1502        Ok(())
1503    }
1504}
1505
1506impl ExecutionPlan for CsrExpandExec {
1507    fn name(&self) -> &str {
1508        "CsrExpandExec"
1509    }
1510
1511    /// Estimated output rows: input rows times the mean degree. Column statistics
1512    /// are unknown. Feeds DataFusion's join sizing above the expansion.
1513    fn partition_statistics(
1514        &self,
1515        partition: Option<usize>,
1516    ) -> datafusion::common::Result<Arc<datafusion::common::Statistics>> {
1517        use datafusion::common::stats::Precision;
1518        let input = self.input.partition_statistics(partition)?;
1519        let mut stats = datafusion::common::Statistics::new_unknown(&self.schema);
1520        if self.traversal.is_some() {
1521            return Ok(Arc::new(stats));
1522        }
1523        let degree = self.adjacency.mean_degree();
1524        stats.num_rows = match input.num_rows {
1525            Precision::Exact(n) | Precision::Inexact(n) => {
1526                Precision::Inexact((n as f64 * degree).ceil() as usize)
1527            }
1528            Precision::Absent => Precision::Absent,
1529        };
1530        Ok(Arc::new(stats))
1531    }
1532
1533    fn properties(&self) -> &Arc<PlanProperties> {
1534        &self.properties
1535    }
1536
1537    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1538        vec![&self.input]
1539    }
1540
1541    fn with_new_children(
1542        self: Arc<Self>,
1543        children: Vec<Arc<dyn ExecutionPlan>>,
1544    ) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
1545        let input = children
1546            .into_iter()
1547            .next()
1548            .expect("CsrExpandExec has one child");
1549        Ok(Arc::new(Self::new(
1550            input,
1551            self.key_column.clone(),
1552            self.adjacency.clone(),
1553            self.target.clone(),
1554            self.target_filter.clone(),
1555            self.closing_column.clone(),
1556            self.traversal.clone(),
1557            self.schema.clone(),
1558        )))
1559    }
1560
1561    fn execute(
1562        &self,
1563        partition: usize,
1564        context: Arc<TaskContext>,
1565    ) -> datafusion::common::Result<SendableRecordBatchStream> {
1566        let input = self.input.execute(partition, context.clone())?;
1567        // Resolve by name at execution time, so later column pruning cannot shift it.
1568        let key_index = input.schema().index_of(&self.key_column)?;
1569        let closing_index = self
1570            .closing_column
1571            .as_deref()
1572            .map(|c| input.schema().index_of(c))
1573            .transpose()?;
1574        let adjacency = self.adjacency.clone();
1575        let target = self.target.clone().map(Arc::new);
1576        let target_filter = self.target_filter.clone().map(Arc::new);
1577        let schema = self.schema.clone();
1578        let out_schema = schema.clone();
1579        let batch_size = context.session_config().batch_size();
1580        let traversal = self.traversal.clone();
1581        // Keep one input and one materialization in flight per partition. A hub's
1582        // remaining neighbours stay in the immutable adjacency until the next poll.
1583        let stream = futures::stream::try_unfold((input, None), move |(mut input, mut pending)| {
1584            let (adjacency, target, target_filter, schema) = (
1585                adjacency.clone(),
1586                target.clone(),
1587                target_filter.clone(),
1588                schema.clone(),
1589            );
1590            let traversal = traversal.clone();
1591            async move {
1592                loop {
1593                    if pending.is_none() {
1594                        let Some(batch) = input.next().await else {
1595                            return Ok(None);
1596                        };
1597                        pending = Some(BatchExpansion::try_new(batch?, key_index, closing_index)?);
1598                    }
1599                    let expansion = pending.as_mut().expect("input batch just set");
1600                    let chunk = match &traversal {
1601                        Some(walk) => {
1602                            expansion
1603                                .next_path_chunk(walk, target.as_deref(), batch_size)
1604                                .await
1605                        }
1606                        None => {
1607                            expansion
1608                                .next_chunk(&adjacency, target.as_deref(), batch_size)
1609                                .await
1610                        }
1611                    };
1612                    if chunk.rows.is_empty() {
1613                        pending = None;
1614                        continue;
1615                    }
1616                    let batch = materialize(
1617                        &expansion.batch,
1618                        chunk,
1619                        target.as_deref(),
1620                        target_filter.as_deref(),
1621                        &schema,
1622                    )
1623                    .await?;
1624                    return Ok(Some((batch, (input, pending))));
1625                }
1626            }
1627        });
1628        Ok(Box::pin(RecordBatchStreamAdapter::new(out_schema, stream)))
1629    }
1630}
1631
1632/// Lowers [`CsrExpandNode`] to [`CsrExpandExec`].
1633#[derive(Debug, Default)]
1634pub struct CsrExtensionPlanner;
1635
1636#[async_trait]
1637impl ExtensionPlanner for CsrExtensionPlanner {
1638    async fn plan_extension(
1639        &self,
1640        _planner: &dyn PhysicalPlanner,
1641        node: &dyn UserDefinedLogicalNode,
1642        _logical_inputs: &[&LogicalPlan],
1643        physical_inputs: &[Arc<dyn ExecutionPlan>],
1644        session_state: &SessionState,
1645    ) -> datafusion::common::Result<Option<Arc<dyn ExecutionPlan>>> {
1646        if let Some(node) = node.as_any().downcast_ref::<crate::apply::ApplyNode>() {
1647            return Ok(Some(crate::apply::plan_apply(
1648                node,
1649                physical_inputs,
1650                session_state,
1651            )));
1652        }
1653        if let Some(node) = node
1654            .as_any()
1655            .downcast_ref::<crate::path_match::PathMatchNode>()
1656        {
1657            return crate::path_match::plan_path_match(node, physical_inputs, session_state)
1658                .map(Some);
1659        }
1660        if let Some(node) = node
1661            .as_any()
1662            .downcast_ref::<crate::shortest_path::ShortestPathNode>()
1663        {
1664            return Ok(Some(crate::shortest_path::plan_shortest_path(
1665                node,
1666                physical_inputs,
1667            )));
1668        }
1669        let Some(node) = node.as_any().downcast_ref::<CsrExpandNode>() else {
1670            return Ok(None);
1671        };
1672        let target_filter = match (&node.target, &node.target_filter) {
1673            (Some(target), Some(filter)) => Some(target.compile_filter(filter, session_state)?),
1674            _ => None,
1675        };
1676        Ok(Some(Arc::new(CsrExpandExec::new(
1677            physical_inputs[0].clone(),
1678            node.key_column.clone(),
1679            node.adjacency.clone(),
1680            node.target.clone(),
1681            target_filter,
1682            node.closing_column.clone(),
1683            node.traversal.clone(),
1684            Arc::new(node.schema.as_arrow().clone()),
1685        ))))
1686    }
1687}
1688
1689/// Query planner that adds [`CsrExtensionPlanner`]; install it on any
1690/// `SessionContext` that executes plans built with an [`AdjacencyCatalog`] or CALL subqueries.
1691#[derive(Debug, Default)]
1692pub struct CsrQueryPlanner;
1693
1694#[async_trait]
1695impl QueryPlanner for CsrQueryPlanner {
1696    async fn create_physical_plan(
1697        &self,
1698        logical_plan: &LogicalPlan,
1699        session_state: &SessionState,
1700    ) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
1701        DefaultPhysicalPlanner::with_extension_planners(vec![Arc::new(CsrExtensionPlanner)])
1702            .create_physical_plan(logical_plan, session_state)
1703            .await
1704    }
1705}
1706
1707/// Optimizer rule: move WHERE conjuncts that read only a fused target's columns into
1708/// the CSR node, which evaluates them before taking the target's other columns.
1709///
1710/// Without this, `... WHERE tc.name = 'Athlete'` takes every projected target column
1711/// for every expanded row, then filters. The CSR node
1712/// instead takes just the filter's columns for each batch's candidates, evaluates the
1713/// predicate, and takes the remaining columns only for rows that pass; columns used
1714/// only by the filter are then pruned from the output. The cost follows the expanded
1715/// rows, never the target table's size. (Evaluating the filter once per query over the
1716/// whole target table was tried first: it cost more than it saved whenever the
1717/// expansion was already small, which re-rooting usually ensures.) Volatile conjuncts
1718/// stay put.
1719#[derive(Debug, Default)]
1720pub struct PushCsrTargetFilters;
1721
1722impl OptimizerRule for PushCsrTargetFilters {
1723    fn name(&self) -> &str {
1724        "push_csr_target_filters"
1725    }
1726
1727    fn rewrite(
1728        &self,
1729        plan: LogicalPlan,
1730        _config: &dyn OptimizerConfig,
1731    ) -> datafusion::common::Result<Transformed<LogicalPlan>> {
1732        plan.transform_up(|node| {
1733            let LogicalPlan::Filter(filter) = &node else {
1734                return Ok(Transformed::no(node));
1735            };
1736            // Projection pushdown can leave column-only projections between the filter
1737            // and the CSR node; look through them (they are rebuilt unchanged below).
1738            let mut projections = Vec::new();
1739            let mut below = filter.input.as_ref();
1740            while let LogicalPlan::Projection(p) = below {
1741                if !p.expr.iter().all(|e| matches!(e, Expr::Column(_))) {
1742                    return Ok(Transformed::no(node));
1743                }
1744                projections.push(p.expr.clone());
1745                below = p.input.as_ref();
1746            }
1747            let LogicalPlan::Extension(ext) = below else {
1748                return Ok(Transformed::no(node));
1749            };
1750            let Some(csr) = ext.node.as_any().downcast_ref::<CsrExpandNode>() else {
1751                return Ok(Transformed::no(node));
1752            };
1753            let Some(target) = &csr.target else {
1754                return Ok(Transformed::no(node));
1755            };
1756            let target_columns = target.raw_columns();
1757            let (moved, kept): (Vec<&Expr>, Vec<&Expr>) = split_conjunction(&filter.predicate)
1758                .into_iter()
1759                .partition(|e| {
1760                    let refs = e.column_refs();
1761                    !refs.is_empty()
1762                        && !e.is_volatile()
1763                        && refs.iter().all(|c| target_columns.contains_key(&c.name))
1764                });
1765            if moved.is_empty() {
1766                return Ok(Transformed::no(node));
1767            }
1768            let mut pushed = csr.clone();
1769            pushed.target_filter = conjunction(
1770                csr.target_filter
1771                    .iter()
1772                    .cloned()
1773                    .chain(moved.into_iter().cloned()),
1774            );
1775            let mut rebuilt = LogicalPlan::Extension(Extension {
1776                node: Arc::new(pushed),
1777            });
1778            for exprs in projections.into_iter().rev() {
1779                rebuilt = LogicalPlanBuilder::from(rebuilt).project(exprs)?.build()?;
1780            }
1781            Ok(Transformed::yes(
1782                match conjunction(kept.into_iter().cloned()) {
1783                    Some(rest) => LogicalPlanBuilder::from(rebuilt).filter(rest)?.build()?,
1784                    None => rebuilt,
1785                },
1786            ))
1787        })
1788    }
1789}
1790
1791/// Optimizer rule: drop fused target columns that nothing in the plan uses.
1792///
1793/// DataFusion's projection pushdown prunes an extension node's inputs but not its
1794/// own outputs, so without this every target column would be fetched per row.
1795/// "Used" is conservative: any column referenced by an expression, the plan's
1796/// output columns, every CSR node's key, and all inputs of nodes whose column use is
1797/// implicit (e.g. `DISTINCT`, `UNION`).
1798#[derive(Debug, Default)]
1799pub struct PruneCsrTargetColumns;
1800
1801impl OptimizerRule for PruneCsrTargetColumns {
1802    fn name(&self) -> &str {
1803        "prune_csr_target_columns"
1804    }
1805
1806    fn rewrite(
1807        &self,
1808        plan: LogicalPlan,
1809        _config: &dyn OptimizerConfig,
1810    ) -> datafusion::common::Result<Transformed<LogicalPlan>> {
1811        let used = used_columns(&plan)?;
1812        plan.transform_up(|node| {
1813            if let LogicalPlan::Extension(ext) = &node {
1814                if let Some(csr) = ext.node.as_any().downcast_ref::<CsrExpandNode>() {
1815                    if let Some(pruned) = csr.with_target_columns(&used) {
1816                        return Ok(Transformed::yes(LogicalPlan::Extension(Extension {
1817                            node: Arc::new(pruned),
1818                        })));
1819                    }
1820                }
1821            }
1822            Ok(Transformed::no(node))
1823        })
1824    }
1825}
1826
1827fn used_columns(plan: &LogicalPlan) -> datafusion::common::Result<HashSet<String>> {
1828    let mut used: HashSet<String> = plan
1829        .schema()
1830        .fields()
1831        .iter()
1832        .map(|f| f.name().clone())
1833        .collect();
1834    plan.apply_with_subqueries(|node| {
1835        for expr in node.expressions() {
1836            used.extend(expr.column_refs().into_iter().map(|c| c.name.clone()));
1837        }
1838        let explicit = match node {
1839            LogicalPlan::Extension(ext) => {
1840                match ext.node.as_any().downcast_ref::<CsrExpandNode>() {
1841                    Some(csr) => {
1842                        // Read by the node itself, not through an expression.
1843                        used.insert(csr.key_column.clone());
1844                        used.extend(csr.closing_column.clone());
1845                        true
1846                    }
1847                    None => false,
1848                }
1849            }
1850            LogicalPlan::Projection(_)
1851            | LogicalPlan::Filter(_)
1852            | LogicalPlan::Aggregate(_)
1853            | LogicalPlan::Sort(_)
1854            | LogicalPlan::Limit(_)
1855            | LogicalPlan::Join(_)
1856            | LogicalPlan::TableScan(_)
1857            | LogicalPlan::SubqueryAlias(_)
1858            | LogicalPlan::Repartition(_)
1859            | LogicalPlan::Window(_)
1860            | LogicalPlan::EmptyRelation(_)
1861            | LogicalPlan::Values(_) => true,
1862            _ => false,
1863        };
1864        if !explicit {
1865            for input in node.inputs() {
1866                used.extend(input.schema().fields().iter().map(|f| f.name().clone()));
1867            }
1868        }
1869        Ok(TreeNodeRecursion::Continue)
1870    })?;
1871    Ok(used)
1872}
1873
1874fn plan_error(message: String) -> GraphError {
1875    GraphError::PlanError {
1876        message,
1877        location: snafu::Location::new(file!(), line!(), column!()),
1878    }
1879}
1880
1881fn exec_error(message: String) -> GraphError {
1882    GraphError::ExecutionError {
1883        message,
1884        location: snafu::Location::new(file!(), line!(), column!()),
1885    }
1886}
1887
1888#[cfg(test)]
1889mod tests {
1890    use super::*;
1891    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
1892
1893    fn edge_stream(src: Vec<Option<i64>>, dst: Vec<Option<i64>>) -> SendableRecordBatchStream {
1894        let schema = Arc::new(Schema::new(vec![
1895            Field::new("src", DataType::Int64, true),
1896            Field::new("dst", DataType::Int64, true),
1897        ]));
1898        let batch = RecordBatch::try_new(
1899            schema.clone(),
1900            vec![
1901                Arc::new(Int64Array::from(src)),
1902                Arc::new(Int64Array::from(dst)),
1903            ],
1904        )
1905        .unwrap();
1906        Box::pin(RecordBatchStreamAdapter::new(
1907            schema,
1908            futures::stream::iter(vec![Ok(batch)]),
1909        ))
1910    }
1911
1912    #[tokio::test]
1913    async fn build_keeps_duplicates_skips_nulls_and_handles_large_ids() {
1914        let big = 1_i64 << 40;
1915        let stream = edge_stream(
1916            vec![Some(big), Some(big), Some(7), None, Some(7)],
1917            vec![Some(1), Some(1), Some(-3), Some(5), None],
1918        );
1919        let adj = Adjacency::build(stream, "SRC", "dst").await.unwrap();
1920        assert_eq!(adj.num_edges(), 3);
1921        assert_eq!(neighbors(&adj, big), vec![1, 1]);
1922        assert_eq!(neighbors(&adj, 7), vec![-3]);
1923        assert!(neighbors(&adj, 42).is_empty());
1924    }
1925
1926    #[tokio::test]
1927    async fn undirected_neighbors_visit_both_directions_and_self_loops_once() {
1928        let edges = || {
1929            edge_stream(
1930                vec![Some(1), Some(1), Some(3)],
1931                vec![Some(2), Some(3), Some(3)],
1932            )
1933        };
1934        let out = Adjacency::build(edges(), "src", "dst").await.unwrap();
1935        let inc = Adjacency::build(edges(), "dst", "src").await.unwrap();
1936        let both = Neighbors::undirected(Arc::new(out), Arc::new(inc));
1937        let collect = |key| {
1938            let mut v = Vec::new();
1939            both.for_each_neighbor(key, |n| v.push(n as i64));
1940            v
1941        };
1942        assert_eq!(collect(1), vec![2, 3]);
1943        assert_eq!(collect(2), vec![1]);
1944        assert_eq!(collect(3), vec![3, 1]);
1945    }
1946
1947    fn neighbors(adj: &Adjacency, key: i64) -> Vec<i64> {
1948        let mut out = Vec::new();
1949        adj.for_each_neighbor(key, |n| out.push(n as i64));
1950        out.sort_unstable();
1951        out
1952    }
1953
1954    #[tokio::test]
1955    async fn changes_layer_over_shared_base_with_multiset_tombstones() {
1956        let base = Adjacency::build(
1957            edge_stream(
1958                vec![Some(1), Some(1), Some(1)],
1959                vec![Some(2), Some(2), Some(3)],
1960            ),
1961            "src",
1962            "dst",
1963        )
1964        .await
1965        .unwrap();
1966        // Remove one of the two 1->2 edges, add 1->4 and a new source 9->1.
1967        let next = base.with_changes(&[(1, 4), (9, 1)], &[(1, 2)]);
1968        assert_eq!(neighbors(&next, 1), vec![2, 3, 4]);
1969        assert_eq!(neighbors(&next, 9), vec![1]);
1970        assert_eq!(
1971            (next.num_edges(), next.delta_edges(), next.tombstones()),
1972            (4, 2, 1)
1973        );
1974        // Removing an appended edge cancels it; the original is untouched.
1975        let later = next.with_changes(&[], &[(9, 1)]);
1976        assert!(neighbors(&later, 9).is_empty());
1977        assert_eq!(neighbors(&base, 1), vec![2, 2, 3]);
1978        assert!(Arc::ptr_eq(&base.base, &later.base));
1979    }
1980
1981    #[tokio::test]
1982    async fn resumed_neighbors_cross_the_base_and_delta_boundary() {
1983        let base = Adjacency::build(
1984            edge_stream(vec![Some(1), Some(1)], vec![Some(10), Some(11)]),
1985            "src",
1986            "dst",
1987        )
1988        .await
1989        .unwrap();
1990        let next = base.with_changes(&[(1, 12), (1, 12), (1, 13)], &[(1, 11), (1, 12)]);
1991        for (mut offset, expected) in [
1992            (0, vec![10, 12, 13]),
1993            (1, vec![12, 13]),
1994            (2, vec![12, 13]),
1995            (3, vec![12, 13]),
1996            (4, vec![13]),
1997            (5, vec![]),
1998            (6, vec![]),
1999        ] {
2000            assert_eq!(
2001                next.neighbors_from(1, &mut offset).collect::<Vec<_>>(),
2002                expected
2003            );
2004        }
2005        assert_eq!(neighbors(&base, 1), vec![10, 11]);
2006    }
2007
2008    #[tokio::test]
2009    async fn debug_output_does_not_grow_with_edges() {
2010        let n = 10_000;
2011        let adj = Adjacency::build(
2012            edge_stream((0..n).map(Some).collect(), (0..n).map(Some).collect()),
2013            "src",
2014            "dst",
2015        )
2016        .await
2017        .unwrap();
2018        assert!(format!("{:?}", adj).len() < 100);
2019    }
2020
2021    #[test]
2022    fn node_rows_reject_duplicate_ids_and_skip_nulls() {
2023        let schema = Arc::new(Schema::new(vec![Field::new("ID", DataType::Int32, true)]));
2024        let batch = |ids: Vec<Option<i32>>| {
2025            RecordBatch::try_new(
2026                schema.clone(),
2027                vec![Arc::new(arrow::array::Int32Array::from(ids))],
2028            )
2029            .unwrap()
2030        };
2031        let rows = NodeRows::from_batch(batch(vec![Some(5), None, Some(7)]), "id")
2032            .unwrap()
2033            .unwrap();
2034        assert_eq!(
2035            (rows.num_rows(), rows.position(7), rows.position(9)),
2036            (2, Some(2), None)
2037        );
2038        assert!(NodeRows::from_batch(batch(vec![Some(5), Some(5)]), "id")
2039            .unwrap()
2040            .is_none());
2041    }
2042
2043    #[tokio::test]
2044    async fn many_small_changes_match_a_reference_multiset() {
2045        let mut adj = Adjacency::build(edge_stream(vec![Some(0)], vec![Some(0)]), "src", "dst")
2046            .await
2047            .unwrap();
2048        let mut expected = vec![0_i64];
2049        for step in 1..=200_i64 {
2050            let removed: Vec<(i64, i64)> = if step % 3 == 0 {
2051                expected.pop().map(|n| vec![(0, n)]).unwrap_or_default()
2052            } else {
2053                vec![]
2054            };
2055            adj = adj.with_changes(&[(0, step), (0, step)], &removed);
2056            expected.extend([step, step]);
2057        }
2058        expected.sort_unstable();
2059        assert_eq!(neighbors(&adj, 0), expected);
2060        assert_eq!(adj.num_edges() as usize, expected.len());
2061    }
2062
2063    #[tokio::test]
2064    async fn expansion_repeats_rows_per_neighbor_and_resumes_between_chunks() {
2065        let adj = Adjacency::build(
2066            edge_stream(vec![Some(1), Some(1)], vec![Some(2), Some(3)]),
2067            "src",
2068            "dst",
2069        )
2070        .await
2071        .unwrap();
2072        let input_schema = Arc::new(Schema::new(vec![Field::new(
2073            "a__id",
2074            DataType::Int32,
2075            true,
2076        )]));
2077        let input = RecordBatch::try_new(
2078            input_schema,
2079            vec![Arc::new(arrow::array::Int32Array::from(vec![
2080                Some(1),
2081                None,
2082                Some(9),
2083            ]))],
2084        )
2085        .unwrap();
2086        let out_schema = Arc::new(Schema::new(vec![
2087            Field::new("a__id", DataType::Int32, true),
2088            Field::new("r__src", DataType::Int32, true),
2089            Field::new("r__dst", DataType::Int32, true),
2090        ]));
2091        let adj = Neighbors::directed(Arc::new(adj));
2092        let mut expansion = BatchExpansion::try_new(input.clone(), 0, None).unwrap();
2093        let chunk = expansion.next_chunk(&adj, None, 8192).await;
2094        let out = materialize(&input, chunk, None, None, &out_schema)
2095            .await
2096            .unwrap();
2097        assert_eq!(out.num_rows(), 2);
2098        let dst = out.column(2).as_primitive::<arrow::datatypes::Int32Type>();
2099        assert_eq!(dst.values().to_vec(), vec![2, 3]);
2100        assert!(expansion.next_chunk(&adj, None, 8192).await.rows.is_empty());
2101        // Output is split at `batch_size` rows.
2102        let mut expansion = BatchExpansion::try_new(input, 0, None).unwrap();
2103        assert_eq!(
2104            expansion.next_chunk(&adj, None, 1).await.neighbor_values,
2105            vec![2]
2106        );
2107        assert_eq!(expansion.row, 0);
2108        assert_eq!(expansion.position.forward, 1);
2109        assert_eq!(
2110            expansion.next_chunk(&adj, None, 1).await.neighbor_values,
2111            vec![3]
2112        );
2113        assert!(expansion.next_chunk(&adj, None, 1).await.rows.is_empty());
2114    }
2115
2116    #[tokio::test]
2117    async fn cancellation_without_edges_in_variable_paths_yields_between_source_rows() {
2118        let config = crate::config::GraphConfig::builder()
2119            .node_table("Person", "id")
2120            .rel_table("KNOWS", "Person", "Person", "src", "dst")
2121            .build()
2122            .unwrap();
2123        let nodes =
2124            RecordBatch::try_from_iter([("id", Arc::new(Int64Array::from(vec![1])) as ArrayRef)])
2125                .unwrap();
2126        let mut catalog = AdjacencyCatalog::default();
2127        catalog.insert_nodes(
2128            "Person",
2129            Arc::new(NodeRows::from_batch(nodes, "id").unwrap().unwrap()),
2130        );
2131        catalog.insert(
2132            "KNOWS",
2133            true,
2134            Arc::new(
2135                Adjacency::build(edge_stream(vec![], vec![]), "src", "dst")
2136                    .await
2137                    .unwrap(),
2138            ),
2139        );
2140        let walk = crate::csr_traversal::Traversal::new(
2141            &config,
2142            &catalog,
2143            &["KNOWS".into()],
2144            &crate::ast::RelationshipDirection::Outgoing,
2145            "Person",
2146            "Person",
2147            1,
2148            1,
2149        )
2150        .unwrap();
2151        let input = RecordBatch::try_from_iter([(
2152            "id",
2153            Arc::new(Int64Array::from(vec![1; 100_000])) as ArrayRef,
2154        )])
2155        .unwrap();
2156        let mut expansion = BatchExpansion::try_new(input, 0, None).unwrap();
2157        let mut next = Box::pin(expansion.next_path_chunk(&walk, None, 1));
2158        assert!(futures::poll!(next.as_mut()).is_pending());
2159        drop(next);
2160        assert!(expansion.row > 0 && expansion.row <= 1024);
2161        assert!(expansion
2162            .next_path_chunk(&walk, None, 1)
2163            .await
2164            .rows
2165            .is_empty());
2166    }
2167
2168    #[tokio::test]
2169    async fn cancellation_without_edges_yields_while_skipping_source_rows() {
2170        let adjacency = Neighbors::directed(Arc::new(
2171            Adjacency::build(edge_stream(vec![Some(1)], vec![Some(2)]), "src", "dst")
2172                .await
2173                .unwrap(),
2174        ));
2175        for missing in [Some(99), None] {
2176            let input = RecordBatch::try_from_iter([(
2177                "id",
2178                Arc::new(Int64Array::from(vec![missing; 100_000])) as ArrayRef,
2179            )])
2180            .unwrap();
2181            let mut expansion = BatchExpansion::try_new(input, 0, None).unwrap();
2182            let mut next = Box::pin(expansion.next_chunk(&adjacency, None, 1));
2183            assert!(futures::poll!(next.as_mut()).is_pending());
2184            drop(next);
2185            assert!(expansion.row > 0 && expansion.row <= 1024);
2186            assert!(expansion
2187                .next_chunk(&adjacency, None, 1)
2188                .await
2189                .rows
2190                .is_empty());
2191        }
2192    }
2193
2194    #[tokio::test]
2195    async fn expansion_yields_before_scanning_all_rejected_neighbors() {
2196        let adj = Neighbors::directed(Arc::new(
2197            Adjacency::build(
2198                edge_stream(vec![Some(1); 100_000], (2..100_002).map(Some).collect()),
2199                "src",
2200                "dst",
2201            )
2202            .await
2203            .unwrap(),
2204        ));
2205        let schema = Arc::new(Schema::new(vec![
2206            Field::new("id", DataType::Int64, false),
2207            Field::new("bound", DataType::Int64, false),
2208        ]));
2209        let batch = RecordBatch::try_new(
2210            schema,
2211            vec![
2212                Arc::new(Int64Array::from(vec![1])),
2213                Arc::new(Int64Array::from(vec![-1])),
2214            ],
2215        )
2216        .unwrap();
2217        let mut expansion = BatchExpansion::try_new(batch, 0, Some(1)).unwrap();
2218        let mut next = Box::pin(expansion.next_chunk(&adj, None, 1));
2219        assert!(futures::poll!(next.as_mut()).is_pending());
2220        drop(next);
2221        assert!(expansion.position.forward <= 1024);
2222        assert!(expansion.position.forward > 0);
2223    }
2224
2225    #[tokio::test]
2226    async fn expansion_yields_and_resumes_through_a_long_tombstone_run() {
2227        let n = 5_000;
2228        let base = Adjacency::build(
2229            edge_stream(vec![Some(1); n], (2..n as i64 + 2).map(Some).collect()),
2230            "src",
2231            "dst",
2232        )
2233        .await
2234        .unwrap();
2235        let removed = (2..n as i64 + 1).map(|id| (1, id)).collect::<Vec<_>>();
2236        let adjacency = Neighbors::directed(Arc::new(base.with_changes(&[], &removed)));
2237        let input =
2238            RecordBatch::try_from_iter([("id", Arc::new(Int64Array::from(vec![1])) as ArrayRef)])
2239                .unwrap();
2240        let mut expansion = BatchExpansion::try_new(input, 0, None).unwrap();
2241        let mut next = Box::pin(expansion.next_chunk(&adjacency, None, 1));
2242        assert!(futures::poll!(next.as_mut()).is_pending());
2243        drop(next);
2244        assert_eq!(expansion.position.forward, 1024);
2245        assert_eq!(
2246            expansion
2247                .next_chunk(&adjacency, None, 1)
2248                .await
2249                .neighbor_values,
2250            vec![n as i64 + 1]
2251        );
2252        assert!(expansion
2253            .next_chunk(&adjacency, None, 1)
2254            .await
2255            .rows
2256            .is_empty());
2257    }
2258
2259    #[tokio::test]
2260    async fn high_degree_expansion_visits_only_the_first_chunk() {
2261        let n = 100_000;
2262        let base = Adjacency::build(
2263            edge_stream(vec![Some(0); n], (1..=n as i64).map(Some).collect()),
2264            "src",
2265            "dst",
2266        )
2267        .await
2268        .unwrap();
2269        let removed: Vec<_> = (1..=n as i64).step_by(2).map(|n| (0, n)).collect();
2270        let adj = Neighbors::directed(Arc::new(base.with_changes(&[], &removed)));
2271        let input =
2272            RecordBatch::try_from_iter([("id", Arc::new(Int64Array::from(vec![0])) as ArrayRef)])
2273                .unwrap();
2274        let mut expansion = BatchExpansion::try_new(input, 0, None).unwrap();
2275        let chunk = expansion.next_chunk(&adj, None, 32).await;
2276        assert_eq!(
2277            chunk.neighbor_values,
2278            (2..=64).step_by(2).collect::<Vec<_>>()
2279        );
2280        assert_eq!(chunk.rows.len(), 32);
2281        // The rest of the hub, including its tombstones, has not been visited.
2282        assert_eq!(expansion.row, 0);
2283        assert_eq!(expansion.position.forward, 64);
2284        assert_eq!(
2285            expansion.next_chunk(&adj, None, 32).await.neighbor_values[0],
2286            66
2287        );
2288        assert_eq!(neighbors(&base, 0).len(), n);
2289    }
2290
2291    #[tokio::test]
2292    async fn chunk_boundaries_preserve_closing_edges_and_missing_target_rows() {
2293        let adjacency = Adjacency::build(
2294            edge_stream(vec![Some(1); 4], vec![Some(2), Some(2), Some(3), Some(999)]),
2295            "src",
2296            "dst",
2297        )
2298        .await
2299        .unwrap();
2300        let adjacency = Neighbors::directed(Arc::new(adjacency));
2301        let input = RecordBatch::try_from_iter([
2302            (
2303                "key",
2304                Arc::new(Int64Array::from(vec![Some(1), Some(1), None, Some(1)])) as ArrayRef,
2305            ),
2306            (
2307                "bound",
2308                Arc::new(Int64Array::from(vec![Some(2), None, Some(2), Some(3)])) as ArrayRef,
2309            ),
2310        ])
2311        .unwrap();
2312        let nodes =
2313            RecordBatch::try_from_iter([("id", Arc::new(Int64Array::from(vec![2])) as ArrayRef)])
2314                .unwrap();
2315        let target = TargetLookup::new(
2316            "b".to_string(),
2317            Arc::new(NodeRows::from_batch(nodes, "id").unwrap().unwrap()),
2318            vec![Field::new("b__id", DataType::Int64, false)],
2319        );
2320        let mut expansion = BatchExpansion::try_new(input, 0, Some(1)).unwrap();
2321        for _ in 0..2 {
2322            let chunk = expansion.next_chunk(&adjacency, Some(&target), 1).await;
2323            assert_eq!(chunk.rows, vec![0]);
2324            assert_eq!(chunk.neighbor_values, vec![2]);
2325            assert_eq!(chunk.target_positions, vec![0]);
2326        }
2327        assert!(expansion
2328            .next_chunk(&adjacency, Some(&target), 1)
2329            .await
2330            .rows
2331            .is_empty());
2332    }
2333
2334    #[tokio::test]
2335    async fn resumed_neighbors_keep_duplicate_deletions_and_self_loops_exact() {
2336        let edges = || edge_stream(vec![Some(1); 4], vec![Some(1), Some(2), Some(2), Some(3)]);
2337        let base = Adjacency::build(edges(), "src", "dst").await.unwrap();
2338        let next = base.with_changes(&[(1, 2), (1, 4)], &[(1, 2), (1, 3)]);
2339        let later = next.with_changes(&[(1, 3)], &[(1, 2), (1, 2)]);
2340        assert_eq!(neighbors(&later, 1), vec![1, 3, 4]);
2341        assert_eq!(neighbors(&next, 1), vec![1, 2, 2, 4]);
2342        assert_eq!(neighbors(&base, 1), vec![1, 2, 2, 3]);
2343
2344        let reverse = Adjacency::build(edges(), "dst", "src").await.unwrap();
2345        let both = Neighbors::undirected(Arc::new(base), Arc::new(reverse));
2346        let mut position = NeighborPosition::default();
2347        let mut got = Vec::new();
2348        while let Some(n) = both.neighbors_from(1, &mut position).next() {
2349            got.push(n);
2350        }
2351        assert_eq!(got, vec![1, 2, 2, 3]);
2352        let mut position = NeighborPosition::default();
2353        assert_eq!(both.neighbors_from(2, &mut position).next(), Some(1));
2354        assert_eq!(both.neighbors_from(2, &mut position).next(), Some(1));
2355        assert_eq!(both.neighbors_from(2, &mut position).next(), None);
2356    }
2357
2358    #[tokio::test]
2359    async fn materialization_reuses_filter_columns_for_memory_and_lance() {
2360        use arrow::array::{RecordBatchIterator, StringArray};
2361        use datafusion::logical_expr::{col, lit};
2362        use std::sync::atomic::Ordering;
2363
2364        let nodes = RecordBatch::try_from_iter([
2365            (
2366                "id",
2367                Arc::new(Int64Array::from(vec![10, 20, 30, 40])) as ArrayRef,
2368            ),
2369            (
2370                "age",
2371                Arc::new(Int64Array::from(vec![Some(2), None, Some(5), Some(1)])) as ArrayRef,
2372            ),
2373            (
2374                "name",
2375                Arc::new(StringArray::from(vec!["ten", "twenty", "thirty", "forty"])) as ArrayRef,
2376            ),
2377            (
2378                "unused",
2379                Arc::new(StringArray::from(vec!["unused payload"; 4])) as ArrayRef,
2380            ),
2381        ])
2382        .unwrap();
2383        let directory = tempfile::tempdir().unwrap();
2384        let path = directory.path().join("nodes.lance");
2385        let reader = RecordBatchIterator::new(vec![Ok(nodes.clone())], nodes.schema());
2386        let dataset = Dataset::write(
2387            reader,
2388            path.to_str().unwrap(),
2389            None::<lance::dataset::WriteParams>,
2390        )
2391        .await
2392        .unwrap();
2393        let sources = [
2394            NodeRows::from_batch(nodes.clone(), "id").unwrap().unwrap(),
2395            NodeRows::from_lance(Arc::new(dataset), "id")
2396                .await
2397                .unwrap()
2398                .unwrap(),
2399        ];
2400        let input = RecordBatch::try_from_iter([(
2401            "a__id",
2402            Arc::new(Int64Array::from(vec![999])) as ArrayRef,
2403        )])
2404        .unwrap();
2405        let state = datafusion::execution::context::SessionContext::new().state();
2406        for source in sources {
2407            let source = Arc::new(source);
2408            for selected in [vec![0, 1, 2], vec![0, 1], vec![2], vec![]] {
2409                let fields: Vec<_> = selected
2410                    .iter()
2411                    .map(|&i| {
2412                        let f = nodes.schema().field(i).clone();
2413                        Field::new(
2414                            format!("b__{}", f.name()),
2415                            f.data_type().clone(),
2416                            f.is_nullable(),
2417                        )
2418                    })
2419                    .collect();
2420                let target = TargetLookup {
2421                    variable: "b".into(),
2422                    rows: source.clone(),
2423                    columns: selected.clone(),
2424                    fields: fields.clone(),
2425                };
2426                let schema = Arc::new(Schema::new(
2427                    [
2428                        Field::new("a__id", DataType::Int64, false),
2429                        Field::new("edge__src", DataType::Int64, false),
2430                        Field::new("edge__dst", DataType::Int64, false),
2431                    ]
2432                    .into_iter()
2433                    .chain(fields)
2434                    .collect::<Vec<_>>(),
2435                ));
2436                for threshold in [2, 99] {
2437                    source.fetched_cells.store(0, Ordering::Relaxed);
2438                    let predicate = col("b__age")
2439                        .gt_eq(lit(threshold))
2440                        .and(col("b__id").gt_eq(lit(10)));
2441                    let filter = target.compile_filter(&predicate, &state).unwrap();
2442                    let neighbors = vec![30, 10, 20, 30, 40, 10];
2443                    let expanded = ExpandedRows {
2444                        rows: vec![0; 6],
2445                        key_values: vec![999; 6],
2446                        target_positions: neighbors
2447                            .iter()
2448                            .map(|&n| source.position(n).unwrap())
2449                            .collect(),
2450                        neighbor_values: neighbors,
2451                    };
2452                    let out = materialize(&input, expanded, Some(&target), Some(&filter), &schema)
2453                        .await
2454                        .unwrap();
2455                    let expected_rows: Vec<u32> = if threshold == 2 {
2456                        vec![2, 0, 2, 0]
2457                    } else {
2458                        vec![]
2459                    };
2460                    assert_eq!(out.num_rows(), expected_rows.len());
2461                    for (i, &column) in selected.iter().enumerate() {
2462                        let expected = take(
2463                            nodes.column(column),
2464                            &UInt32Array::from(expected_rows.clone()),
2465                            None,
2466                        )
2467                        .unwrap();
2468                        assert_eq!(out.column(3 + i).to_data(), expected.to_data());
2469                    }
2470                    // Only age is read for six candidates; only name needs a later read.
2471                    let projection_cells = if selected.contains(&2) {
2472                        expected_rows.len()
2473                    } else {
2474                        0
2475                    };
2476                    assert_eq!(
2477                        source.fetched_cells.load(Ordering::Relaxed),
2478                        6 + projection_cells
2479                    );
2480                }
2481            }
2482        }
2483    }
2484}