Skip to main content

ursa_engine/graph/
exec.rs

1//! Executing a graph node.
2//!
3//! [`GraphPlanner`] maps a [`GraphAlgorithmNode`] to a [`GraphAlgorithmExec`]
4//! whose children are the planned edge plans. When it executes, it looks up
5//! the index in the session's [`IndexCache`]; on a miss it reads a child,
6//! partition by partition, and builds the index as the rows arrive. Then it runs the calls and
7//! emits one batch.
8//!
9//! Row order matters to the index: dense node ids are given in first-seen
10//! order, and they decide component labels and the order floating-point sums
11//! run in. The child's partitions are read in order, the node asks for no
12//! repartitioning of its input, and the session turns off file-scan work
13//! stealing, so a file's edges arrive in the file's own order on any thread
14//! count, as `ursa-io` reads them for the Python library.
15//!
16//! ## A vertex table
17//!
18//! A graph with a vertex table has its keys as its nodes (#311). Its node has
19//! one more child, the keys ([`GraphAlgorithmNode::vertices`]), which the
20//! build reads first: each key takes the next dense id, in the table's order,
21//! so a node on no edge is a node all the same. The edges are read after,
22//! and an edge whose source or destination is not a key is dropped, as an
23//! inner join of the edges with the vertex table drops it (DuckPGQ's CSR is
24//! built that way). The index counts them ([`GraphIndex::n_dropped`]), for
25//! `describe`, and keeps which rows it kept, so weights read row by row line
26//! up with the kept edges and a path's edges are still the edge table's rows.
27//! The keys' plan is part of the index's cache key: its fingerprint, and the
28//! identity of the files it reads, so a changed vertex table builds afresh.
29//!
30//! ## Weights
31//!
32//! Weights are looked up by edge row, so they must be in the order of the
33//! rows the index was built from. A weighted node reads only its weighted
34//! child (the endpoints and every weight expression, in one pass, evaluating
35//! the weights a batch at a time as it goes), and:
36//!
37//! - on an index-cache miss, builds the index from those same rows, so the
38//!   weights line up by construction;
39//! - on a hit, checks that the rows' endpoints are the index's, row for row
40//!   (an edge plan over a join need not produce its rows in the same order
41//!   twice). If they are not, it reads the child again and builds a private
42//!   index from those rows rather than mis-assign a weight.
43//!
44//! Each distinct weight expression is evaluated once per node, cast to
45//! `Float64`, and checked as `ursa-plan` checks weights (no nulls, no negative
46//! values, with the same messages). Weights that line up with a cached index
47//! are stored on it, keyed by the weighted plan's fingerprint and file
48//! identities, so the next query with the same weights reads no edges at all.
49//! In a database, they are stored with the index on disk too, so a weighted
50//! query over a stored index in a later process reads no edges either.
51
52use std::fmt;
53use std::sync::{Arc, Mutex};
54
55use async_trait::async_trait;
56use datafusion::arrow::array::{new_empty_array, Array, ArrayRef, BooleanArray, Float64Array};
57use datafusion::arrow::buffer::BooleanBuffer;
58use datafusion::arrow::compute::SortOptions;
59use datafusion::arrow::compute::{cast, cast_with_options, concat, CastOptions};
60use datafusion::arrow::compute::{filter, filter_record_batch};
61use datafusion::arrow::datatypes::{DataType, SchemaRef};
62use datafusion::arrow::record_batch::RecordBatch;
63use datafusion::catalog::Session;
64use datafusion::common::config::ConfigOptions;
65use datafusion::common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
66use datafusion::common::ScalarValue;
67use datafusion::error::{DataFusionError, Result};
68use datafusion::execution::context::QueryPlanner;
69use datafusion::execution::TaskContext;
70use datafusion::logical_expr::physical_planning_context::PhysicalPlanningContext;
71use datafusion::logical_expr::{LogicalPlan, UserDefinedLogicalNode};
72use datafusion::physical_expr::expressions::Column;
73use datafusion::physical_expr::{EquivalenceProperties, PhysicalExpr};
74use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
75use datafusion::physical_optimizer::PhysicalOptimizerRule;
76use datafusion::physical_plan::aggregates::AggregateExec;
77use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
78use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
79use datafusion::physical_plan::repartition::RepartitionExec;
80use datafusion::physical_plan::replace_children_if_necessary;
81use datafusion::physical_plan::sorts::sort::SortExec;
82use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
83use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
84use datafusion::physical_plan::union::{InterleaveExec, UnionExec};
85use datafusion::physical_plan::{
86    DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, Partitioning,
87    PlanProperties, SendableRecordBatchStream, StatisticsArgs, StatisticsContext,
88};
89use datafusion::physical_planner::{DefaultPhysicalPlanner, ExtensionPlanner, PhysicalPlanner};
90use futures::{FutureExt, StreamExt, TryStreamExt};
91use ursa_core::{EdgeInterner, IdError, Topology};
92
93use super::index::{file_identities, fingerprint, FileIdentity, GraphIndex, IndexCache, Weights};
94use super::kernels;
95use super::node::{
96    weight_column, GraphAlgorithmNode, GraphTraversalNode, Output, WEIGHTED_DESTINATION,
97    WEIGHTED_SOURCE,
98};
99use super::path_search::{self, PathColumns, PathSearchNode, Search};
100use super::traversal::{self, Traversal, TraversalParam, TraversalParamType};
101
102/// Plans Ursa's graph nodes.
103#[derive(Debug)]
104pub struct GraphPlanner;
105
106#[async_trait]
107impl ExtensionPlanner for GraphPlanner {
108    async fn plan_extension(
109        &self,
110        _planner: &dyn PhysicalPlanner,
111        node: &dyn UserDefinedLogicalNode,
112        logical_inputs: &[&LogicalPlan],
113        physical_inputs: &[Arc<dyn ExecutionPlan>],
114        _session_state: &dyn Session,
115        _planning_ctx: &PhysicalPlanningContext,
116    ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
117        if let Some(node) = node.as_any().downcast_ref::<GraphTraversalNode>() {
118            return plan_traversal(node, logical_inputs, physical_inputs).map(Some);
119        }
120        if let Some(node) = node.as_any().downcast_ref::<PathSearchNode>() {
121            return plan_path_search(node, logical_inputs, physical_inputs).map(Some);
122        }
123        let Some(node) = node.as_any().downcast_ref::<GraphAlgorithmNode>() else {
124            return Ok(None);
125        };
126        let (source, destination) = node.endpoint_indices()?;
127        let mut next = 1;
128        let weighted = match (
129            &node.weighted,
130            logical_inputs.get(next),
131            physical_inputs.get(next),
132        ) {
133            (Some(_), Some(logical), Some(physical)) => {
134                next += 1;
135                Some(weighted_input(logical, physical, node.weights.len())?)
136            }
137            _ => None,
138        };
139        let vertices = vertex_input(
140            node.vertices.is_some(),
141            logical_inputs,
142            physical_inputs,
143            next,
144        )?;
145        // A neighbour aggregation's node values are the last input.
146        let values = node
147            .values
148            .as_ref()
149            .and_then(|_| physical_inputs.last().cloned());
150        Ok(Some(Arc::new(GraphAlgorithmExec::new(
151            Shape {
152                edges: Edges {
153                    graph: node.graph.clone(),
154                    endpoints: (source, destination),
155                    id_type: node.id_type.clone(),
156                    fingerprint: fingerprint(logical_inputs[0]),
157                    vertices,
158                },
159                outputs: node.outputs.clone(),
160                per_node: node.per_node,
161                display: node.display_outputs(),
162                schema: Arc::new(node.schema().as_arrow().clone()),
163            },
164            Arc::clone(&physical_inputs[0]),
165            weighted,
166            values,
167        ))))
168    }
169}
170
171/// The vertex keys input at `at`, if the node has one.
172fn vertex_input(
173    has: bool,
174    logical_inputs: &[&LogicalPlan],
175    physical_inputs: &[Arc<dyn ExecutionPlan>],
176    at: usize,
177) -> Result<Option<VertexInput>> {
178    if !has {
179        return Ok(None);
180    }
181    match (logical_inputs.get(at), physical_inputs.get(at)) {
182        (Some(logical), Some(physical)) => Ok(Some(VertexInput {
183            plan: Arc::clone(physical),
184            fingerprint: fingerprint(logical),
185        })),
186        _ => Err(DataFusionError::Internal(
187            "a graph node's vertex keys were not planned".to_string(),
188        )),
189    }
190}
191
192/// The weighted edge plan `physical` (planned from `logical`), with
193/// `n_weights` weight columns.
194fn weighted_input(
195    logical: &LogicalPlan,
196    physical: &Arc<dyn ExecutionPlan>,
197    n_weights: usize,
198) -> Result<WeightedInput> {
199    let schema = logical.schema();
200    let column = |name: &str| schema.index_of_column_by_name(None, name);
201    let missing =
202        |name: &str| DataFusionError::Internal(format!("the weighted edge plan has no {name}"));
203    let weights = (0..n_weights)
204        .map(|i| {
205            let name = weight_column(i);
206            column(&name).ok_or_else(|| missing(&name))
207        })
208        .collect::<Result<Vec<_>>>()?;
209    Ok(WeightedInput {
210        plan: Arc::clone(physical),
211        source: column(WEIGHTED_SOURCE).ok_or_else(|| missing(WEIGHTED_SOURCE))?,
212        destination: column(WEIGHTED_DESTINATION).ok_or_else(|| missing(WEIGHTED_DESTINATION))?,
213        weights,
214        fingerprint: fingerprint(logical),
215    })
216}
217
218/// The exec of a traversal node.
219fn plan_traversal(
220    node: &GraphTraversalNode,
221    logical_inputs: &[&LogicalPlan],
222    physical_inputs: &[Arc<dyn ExecutionPlan>],
223) -> Result<Arc<dyn ExecutionPlan>> {
224    let expected = 1
225        + usize::from(node.weighted.is_some())
226        + usize::from(node.vertices.is_some())
227        + node.nodes.len();
228    if logical_inputs.len() != expected || physical_inputs.len() != expected {
229        return Err(DataFusionError::Internal(format!(
230            "GraphTraversal has {expected} inputs; planned {}",
231            physical_inputs.len()
232        )));
233    }
234    let (source, destination) = node.endpoint_indices()?;
235    let mut next = 1;
236    let weighted = match node.weighted {
237        Some(_) => {
238            next += 1;
239            Some(weighted_input(logical_inputs[1], &physical_inputs[1], 1)?)
240        }
241        None => None,
242    };
243    let vertices = vertex_input(
244        node.vertices.is_some(),
245        logical_inputs,
246        physical_inputs,
247        next,
248    )?;
249    next += usize::from(vertices.is_some());
250    Ok(Arc::new(GraphTraversalExec::new(
251        TraversalShape {
252            edges: Edges {
253                graph: node.graph.clone(),
254                endpoints: (source, destination),
255                id_type: node.id_type.clone(),
256                fingerprint: fingerprint(logical_inputs[0]),
257                vertices,
258            },
259            traversal: node.traversal,
260            display: node.display_call(),
261            schema: Arc::new(node.schema().as_arrow().clone()),
262        },
263        Arc::clone(&physical_inputs[0]),
264        weighted,
265        physical_inputs[next..].to_vec(),
266    )))
267}
268
269fn plan_path_search(
270    node: &PathSearchNode,
271    logical_inputs: &[&LogicalPlan],
272    physical_inputs: &[Arc<dyn ExecutionPlan>],
273) -> Result<Arc<dyn ExecutionPlan>> {
274    let expected = 2 + usize::from(node.vertices.is_some());
275    if physical_inputs.len() != expected || logical_inputs.len() != expected {
276        return Err(DataFusionError::Internal(format!(
277            "PathSearch has {expected} inputs; planned {}",
278            physical_inputs.len()
279        )));
280    }
281    let vertices = vertex_input(node.vertices.is_some(), logical_inputs, physical_inputs, 1)?;
282    Ok(Arc::new(PathSearchExec::new(
283        PathShape {
284            edges: Edges {
285                graph: node.graph.clone(),
286                endpoints: node.endpoint_indices()?,
287                id_type: node.id_type.clone(),
288                fingerprint: fingerprint(logical_inputs[0]),
289                vertices,
290            },
291            search: node.search,
292            columns: node.columns,
293            schema: Arc::new(node.schema().as_arrow().clone()),
294        },
295        Arc::clone(&physical_inputs[0]),
296        Arc::clone(physical_inputs.last().expect("checked above")),
297    )))
298}
299
300#[derive(Debug, Clone)]
301struct PathShape {
302    edges: Edges,
303    search: Search,
304    columns: PathColumns,
305    schema: SchemaRef,
306}
307
308/// Builds or fetches a graph index from its first child's edge rows, reads
309/// the start nodes its second child produces, and searches paths from them
310/// (see [`super::path_search`]).
311#[derive(Debug)]
312pub struct PathSearchExec {
313    shape: PathShape,
314    input: Arc<dyn ExecutionPlan>,
315    starts: Arc<dyn ExecutionPlan>,
316    properties: Arc<PlanProperties>,
317}
318
319impl PathSearchExec {
320    fn new(
321        shape: PathShape,
322        input: Arc<dyn ExecutionPlan>,
323        starts: Arc<dyn ExecutionPlan>,
324    ) -> Self {
325        let properties = Arc::new(PlanProperties::new(
326            EquivalenceProperties::new(Arc::clone(&shape.schema)),
327            Partitioning::UnknownPartitioning(1),
328            EmissionType::Final,
329            Boundedness::Bounded,
330        ));
331        Self {
332            shape,
333            input,
334            starts,
335            properties,
336        }
337    }
338}
339
340impl DisplayAs for PathSearchExec {
341    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
342        write!(
343            f,
344            "PathSearchExec: graph={}, search={}",
345            self.shape.edges.graph, self.shape.search
346        )
347    }
348}
349
350impl ExecutionPlan for PathSearchExec {
351    fn name(&self) -> &str {
352        "PathSearchExec"
353    }
354
355    fn properties(&self) -> &Arc<PlanProperties> {
356        &self.properties
357    }
358
359    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
360        let mut children = vec![&self.input];
361        children.extend(self.shape.edges.vertices.as_ref().map(|v| &v.plan));
362        children.push(&self.starts);
363        children
364    }
365
366    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
367        vec![false; self.children().len()]
368    }
369
370    fn apply_expressions(
371        &self,
372        _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
373    ) -> Result<TreeNodeRecursion> {
374        Ok(TreeNodeRecursion::Continue)
375    }
376
377    fn with_new_children(
378        self: Arc<Self>,
379        children: Vec<Arc<dyn ExecutionPlan>>,
380    ) -> Result<Arc<dyn ExecutionPlan>> {
381        let expected = self.children().len();
382        if children.len() != expected {
383            return Err(DataFusionError::Internal(format!(
384                "PathSearchExec has {expected} children; got {}",
385                children.len()
386            )));
387        }
388        let mut children = children.into_iter();
389        let input = children.next().expect("checked above");
390        let mut shape = self.shape.clone();
391        shape.edges.vertices = shape.edges.vertices.map(|v| VertexInput {
392            plan: children.next().expect("checked above"),
393            ..v
394        });
395        let starts = children.next().expect("checked above");
396        Ok(Arc::new(Self::new(shape, input, starts)))
397    }
398
399    fn execute(
400        &self,
401        partition: usize,
402        context: Arc<TaskContext>,
403    ) -> Result<SendableRecordBatchStream> {
404        if partition != 0 {
405            return Err(DataFusionError::Internal(format!(
406                "PathSearchExec has one partition; asked for {partition}"
407            )));
408        }
409        let shape = self.shape.clone();
410        let input = Arc::clone(&self.input);
411        let starts = Arc::clone(&self.starts);
412        let schema = Arc::clone(&shape.schema);
413        let run = async move {
414            let index = unweighted_index(&shape.edges, input, &context).await?;
415            let batches = drain(&starts, &[0], &context).await?;
416            let id_type = shape.edges.id_type.clone();
417            let mut columns = Vec::with_capacity(batches.len());
418            for batch in &batches {
419                columns.push(cast(batch[0].as_ref(), &id_type).map_err(|_| {
420                    DataFusionError::Plan(format!(
421                        "in GRAPH_TABLE: the start nodes' keys ({}) cannot be read as graph '{}''s node ids ({id_type})",
422                        batch[0].data_type(),
423                        shape.edges.graph
424                    ))
425                })?);
426            }
427            let starts: ArrayRef = if columns.is_empty() {
428                new_empty_array(&id_type)
429            } else {
430                let refs: Vec<&dyn Array> = columns.iter().map(|c| c.as_ref()).collect();
431                concat(&refs)?
432            };
433            let out_schema = Arc::clone(&shape.schema);
434            let batches = crate::run_blocking(move || {
435                path_search::run(
436                    &index.topology,
437                    &index.ids,
438                    &|e| index.edge_row(e),
439                    &shape.search,
440                    &starts,
441                    shape.columns,
442                    out_schema,
443                )
444            })
445            .await
446            .map_err(|e| DataFusionError::Execution(format!("a path search failed: {e}")))??;
447            Ok::<_, DataFusionError>(futures::stream::iter(batches.into_iter().map(Ok)))
448        };
449        Ok(Box::pin(RecordBatchStreamAdapter::new(
450            schema,
451            futures::stream::once(run).try_flatten(),
452        )))
453    }
454}
455
456/// A query planner whose physical planner knows Ursa's graph nodes.
457#[derive(Debug)]
458pub struct UrsaQueryPlanner;
459
460#[async_trait]
461impl QueryPlanner for UrsaQueryPlanner {
462    async fn create_physical_plan(
463        &self,
464        logical_plan: &LogicalPlan,
465        session_state: &dyn Session,
466    ) -> Result<Arc<dyn ExecutionPlan>> {
467        DefaultPhysicalPlanner::with_extension_planners(vec![Arc::new(GraphPlanner)])
468            .create_physical_plan(logical_plan, session_state)
469            .await
470    }
471}
472
473/// A graph node's edge input: where its endpoints are, and how its index is
474/// cached.
475#[derive(Debug, Clone)]
476struct Edges {
477    graph: String,
478    /// The source and destination columns of the first input.
479    endpoints: (usize, usize),
480    id_type: DataType,
481    /// The edge plan's fingerprint, or `None` if it cannot be cached.
482    fingerprint: Option<String>,
483    /// The graph's vertex keys, if it has a vertex table.
484    vertices: Option<VertexInput>,
485}
486
487/// A graph node's vertex keys: a plan of one column (see the module docs).
488#[derive(Debug, Clone)]
489struct VertexInput {
490    plan: Arc<dyn ExecutionPlan>,
491    /// The keys' plan's fingerprint, or `None` if it cannot be cached.
492    fingerprint: Option<String>,
493}
494
495/// What a [`GraphAlgorithmExec`] computes, besides its children.
496#[derive(Debug, Clone)]
497struct Shape {
498    edges: Edges,
499    outputs: Vec<Output>,
500    per_node: bool,
501    /// The outputs as `EXPLAIN` shows them.
502    display: String,
503    schema: SchemaRef,
504}
505
506/// The weighted edge plan, and where its columns are.
507#[derive(Debug, Clone)]
508struct WeightedInput {
509    plan: Arc<dyn ExecutionPlan>,
510    source: usize,
511    destination: usize,
512    /// One column per weight expression, in the node's order.
513    weights: Vec<usize>,
514    /// The weighted plan's fingerprint, or `None` if it cannot be cached.
515    fingerprint: Option<String>,
516}
517
518/// Builds or fetches a graph index from its child's edge rows, and runs
519/// graph functions over it.
520#[derive(Debug)]
521pub struct GraphAlgorithmExec {
522    shape: Shape,
523    input: Arc<dyn ExecutionPlan>,
524    weighted: Option<WeightedInput>,
525    /// A neighbour aggregation's node values: node ids, then values.
526    values: Option<Arc<dyn ExecutionPlan>>,
527    properties: Arc<PlanProperties>,
528}
529
530impl GraphAlgorithmExec {
531    fn new(
532        shape: Shape,
533        input: Arc<dyn ExecutionPlan>,
534        weighted: Option<WeightedInput>,
535        values: Option<Arc<dyn ExecutionPlan>>,
536    ) -> Self {
537        let properties = Arc::new(PlanProperties::new(
538            EquivalenceProperties::new(Arc::clone(&shape.schema)),
539            Partitioning::UnknownPartitioning(1),
540            EmissionType::Final,
541            Boundedness::Bounded,
542        ));
543        Self {
544            shape,
545            input,
546            weighted,
547            values,
548            properties,
549        }
550    }
551}
552
553impl DisplayAs for GraphAlgorithmExec {
554    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
555        write!(
556            f,
557            "GraphAlgorithmExec: graph={}, outputs=[{}]",
558            self.shape.edges.graph, self.shape.display
559        )
560    }
561}
562
563impl ExecutionPlan for GraphAlgorithmExec {
564    fn name(&self) -> &str {
565        "GraphAlgorithmExec"
566    }
567
568    fn properties(&self) -> &Arc<PlanProperties> {
569        &self.properties
570    }
571
572    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
573        let mut children = vec![&self.input];
574        children.extend(self.weighted.as_ref().map(|w| &w.plan));
575        children.extend(self.shape.edges.vertices.as_ref().map(|v| &v.plan));
576        children.extend(self.values.as_ref());
577        children
578    }
579
580    /// The edges are read partition by partition in order; splitting them
581    /// further would only scramble that order.
582    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
583        vec![false; self.children().len()]
584    }
585
586    fn apply_expressions(
587        &self,
588        _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
589    ) -> Result<TreeNodeRecursion> {
590        Ok(TreeNodeRecursion::Continue)
591    }
592
593    fn with_new_children(
594        self: Arc<Self>,
595        children: Vec<Arc<dyn ExecutionPlan>>,
596    ) -> Result<Arc<dyn ExecutionPlan>> {
597        let expected = self.children().len();
598        if children.len() != expected {
599            return Err(DataFusionError::Internal(format!(
600                "GraphAlgorithmExec has {expected} children; got {}",
601                children.len()
602            )));
603        }
604        let mut children = children.into_iter();
605        let input = children.next().expect("checked above");
606        let weighted = self.weighted.clone().map(|w| WeightedInput {
607            plan: children.next().expect("checked above"),
608            ..w
609        });
610        let mut shape = self.shape.clone();
611        shape.edges.vertices = shape.edges.vertices.map(|v| VertexInput {
612            plan: children.next().expect("checked above"),
613            ..v
614        });
615        let values = self
616            .values
617            .as_ref()
618            .map(|_| children.next().expect("checked above"));
619        Ok(Arc::new(Self::new(shape, input, weighted, values)))
620    }
621
622    fn execute(
623        &self,
624        partition: usize,
625        context: Arc<TaskContext>,
626    ) -> Result<SendableRecordBatchStream> {
627        if partition != 0 {
628            return Err(DataFusionError::Internal(format!(
629                "GraphAlgorithmExec has one partition; asked for {partition}"
630            )));
631        }
632        let shape = self.shape.clone();
633        let input = Arc::clone(&self.input);
634        let weighted = self.weighted.clone();
635        let values_plan = self.values.clone();
636        let schema = Arc::clone(&shape.schema);
637        let run = async move {
638            let (index, weights) = match weighted {
639                None => (unweighted_index(&shape.edges, input, &context).await?, None),
640                Some(w) => {
641                    let (index, weights) = weighted_index(&shape.edges, input, w, &context).await?;
642                    (index, Some(weights))
643                }
644            };
645            let values = match values_plan {
646                Some(plan) => {
647                    let batches = drain(&plan, &[0, 1], &context).await?;
648                    Some(kernels::node_values(&index, &shape.edges.graph, batches)?)
649                }
650                None => None,
651            };
652            // The kernels are CPU-bound and parallel (rayon): keep them off
653            // the async workers (inline in the browser, which has none).
654            crate::run_blocking(move || {
655                let weights = weights.as_deref().map_or(&[][..], Vec::as_slice);
656                kernels::batch(
657                    &index,
658                    &shape.outputs,
659                    weights,
660                    values.as_ref(),
661                    shape.per_node,
662                    shape.schema,
663                )
664            })
665            .await
666            .map_err(|e| DataFusionError::Execution(format!("a graph kernel failed: {e}")))?
667        };
668        Ok(Box::pin(RecordBatchStreamAdapter::new(
669            schema,
670            futures::stream::once(run),
671        )))
672    }
673}
674
675/// What a [`GraphTraversalExec`] computes, besides its children.
676#[derive(Debug, Clone)]
677struct TraversalShape {
678    edges: Edges,
679    traversal: Traversal,
680    /// The call as `EXPLAIN` shows it.
681    display: String,
682    schema: SchemaRef,
683}
684
685/// Builds or fetches a graph index from its first child's edge rows, reads
686/// the node ids its other children produce, and runs a traversal.
687#[derive(Debug)]
688pub struct GraphTraversalExec {
689    shape: TraversalShape,
690    input: Arc<dyn ExecutionPlan>,
691    weighted: Option<WeightedInput>,
692    /// One plan per node-id parameter, in the spec's order.
693    nodes: Vec<Arc<dyn ExecutionPlan>>,
694    properties: Arc<PlanProperties>,
695}
696
697impl GraphTraversalExec {
698    fn new(
699        shape: TraversalShape,
700        input: Arc<dyn ExecutionPlan>,
701        weighted: Option<WeightedInput>,
702        nodes: Vec<Arc<dyn ExecutionPlan>>,
703    ) -> Self {
704        let properties = Arc::new(PlanProperties::new(
705            EquivalenceProperties::new(Arc::clone(&shape.schema)),
706            Partitioning::UnknownPartitioning(1),
707            EmissionType::Final,
708            Boundedness::Bounded,
709        ));
710        Self {
711            shape,
712            input,
713            weighted,
714            nodes,
715            properties,
716        }
717    }
718}
719
720impl DisplayAs for GraphTraversalExec {
721    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
722        write!(
723            f,
724            "GraphTraversalExec: graph={}, call={}",
725            self.shape.edges.graph, self.shape.display
726        )
727    }
728}
729
730impl ExecutionPlan for GraphTraversalExec {
731    fn name(&self) -> &str {
732        "GraphTraversalExec"
733    }
734
735    fn properties(&self) -> &Arc<PlanProperties> {
736        &self.properties
737    }
738
739    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
740        let mut children = vec![&self.input];
741        children.extend(self.weighted.as_ref().map(|w| &w.plan));
742        children.extend(self.shape.edges.vertices.as_ref().map(|v| &v.plan));
743        children.extend(self.nodes.iter());
744        children
745    }
746
747    /// The edges are read in order (see [`GraphAlgorithmExec`]), and the node
748    /// ids in the order their query gives them, which sets the order of the
749    /// rows.
750    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
751        vec![false; self.children().len()]
752    }
753
754    fn apply_expressions(
755        &self,
756        _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
757    ) -> Result<TreeNodeRecursion> {
758        Ok(TreeNodeRecursion::Continue)
759    }
760
761    fn with_new_children(
762        self: Arc<Self>,
763        children: Vec<Arc<dyn ExecutionPlan>>,
764    ) -> Result<Arc<dyn ExecutionPlan>> {
765        let expected = self.children().len();
766        if children.len() != expected {
767            return Err(DataFusionError::Internal(format!(
768                "GraphTraversalExec has {expected} children; got {}",
769                children.len()
770            )));
771        }
772        let mut children = children.into_iter();
773        let input = children.next().expect("checked above");
774        let weighted = self.weighted.clone().map(|w| WeightedInput {
775            plan: children.next().expect("checked above"),
776            ..w
777        });
778        let mut shape = self.shape.clone();
779        shape.edges.vertices = shape.edges.vertices.map(|v| VertexInput {
780            plan: children.next().expect("checked above"),
781            ..v
782        });
783        Ok(Arc::new(Self::new(
784            shape,
785            input,
786            weighted,
787            children.collect(),
788        )))
789    }
790
791    fn execute(
792        &self,
793        partition: usize,
794        context: Arc<TaskContext>,
795    ) -> Result<SendableRecordBatchStream> {
796        if partition != 0 {
797            return Err(DataFusionError::Internal(format!(
798                "GraphTraversalExec has one partition; asked for {partition}"
799            )));
800        }
801        let shape = self.shape.clone();
802        let input = Arc::clone(&self.input);
803        let weighted = self.weighted.clone();
804        let node_plans = self.nodes.clone();
805        let schema = Arc::clone(&shape.schema);
806        let run = async move {
807            let (index, weights) = match weighted {
808                None => (unweighted_index(&shape.edges, input, &context).await?, None),
809                Some(w) => {
810                    let (index, weights) = weighted_index(&shape.edges, input, w, &context).await?;
811                    (index, Some(weights))
812                }
813            };
814            let spec = shape.traversal.spec();
815            let mut nodes = Vec::with_capacity(node_plans.len());
816            for (param, plan) in spec.node_params().zip(&node_plans) {
817                let batches = drain(plan, &[0], &context).await?;
818                nodes.push(node_ids(&shape, param, &index, batches)?);
819            }
820            crate::run_blocking(move || {
821                let weights = weights.as_ref().map(|w| w[0].as_slice());
822                traversal::batch(
823                    &index.topology,
824                    &index.ids,
825                    &shape.traversal,
826                    &nodes,
827                    weights,
828                    shape.schema,
829                )
830            })
831            .await
832            .map_err(|e| DataFusionError::Execution(format!("a graph kernel failed: {e}")))?
833        };
834        Ok(Box::pin(RecordBatchStreamAdapter::new(
835            schema,
836            futures::stream::once(run),
837        )))
838    }
839}
840
841/// The ids a node-id input produced, as dense indexes of `index` (`None` for a
842/// null or an id not in the graph), cast to the graph's id type first.
843fn node_ids(
844    shape: &TraversalShape,
845    param: &TraversalParam,
846    index: &GraphIndex,
847    batches: Vec<Vec<ArrayRef>>,
848) -> Result<Vec<Option<u32>>> {
849    let (function, name, graph) = (shape.traversal.name(), param.name, &shape.edges.graph);
850    let id_type = index.ids.user_type();
851    let options = CastOptions {
852        safe: false,
853        ..CastOptions::default()
854    };
855    let arrays = batches
856        .iter()
857        .map(|b| {
858            // No float, boolean or date, and a string must be exactly an
859            // integer on a graph with integer ids (#271).
860            if let Some(row) = traversal::first_refused_node_id(&b[0], &id_type) {
861                let got = sql_text(&b[0], row)
862                    .unwrap_or_else(|| format!("a value of type {}", b[0].data_type()));
863                return Err(DataFusionError::Execution(traversal::not_node_ids(
864                    function, param, graph, &id_type, &got,
865                )));
866            }
867            cast_with_options(&b[0], &id_type, &options).map_err(|e| {
868                let got = first_uncast(&b[0], &id_type).unwrap_or_else(|| {
869                    crate::user_message(&DataFusionError::ArrowError(Box::new(e), None))
870                });
871                DataFusionError::Execution(traversal::not_node_ids(
872                    function, param, graph, &id_type, &got,
873                ))
874            })
875        })
876        .collect::<Result<Vec<_>>>()?;
877    let refs: Vec<&dyn Array> = arrays.iter().map(|a| a.as_ref()).collect();
878    let ids = match refs.as_slice() {
879        [] => new_empty_array(&id_type),
880        _ => concat(&refs)?,
881    };
882    if param.ty == TraversalParamType::Node && ids.len() > 1 {
883        return Err(DataFusionError::Execution(format!(
884            "{function}({name} =>) must be one node id; its query returned {} rows",
885            ids.len()
886        )));
887    }
888    index
889        .ids
890        .dense_from_array(ids.as_ref())
891        .map_err(|e| DataFusionError::Execution(format!("{function}({name} =>): {e}")))
892}
893
894/// The first value of `values` that does not cast to `id_type`, as SQL writes
895/// it (`'Valjean'`, `2.5`), for a message.
896fn first_uncast(values: &ArrayRef, id_type: &DataType) -> Option<String> {
897    let lenient = cast(values, id_type).ok()?;
898    let row = (0..values.len()).find(|&i| values.is_valid(i) && lenient.is_null(i))?;
899    sql_text(values, row)
900}
901
902/// Row `row` of `values` as SQL writes it (`'Valjean'`, `2.5`).
903fn sql_text(values: &ArrayRef, row: usize) -> Option<String> {
904    let mut value = ScalarValue::try_from_array(values, row).ok()?;
905    while let ScalarValue::Dictionary(_, inner) = value {
906        value = *inner;
907    }
908    Some(match value.try_as_str() {
909        Some(Some(s)) => format!("'{}'", s.replace('\'', "''")),
910        _ => value.to_string(),
911    })
912}
913
914/// Keeps a graph node's edge rows in the order the plan produces them, and
915/// gives them a fixed order where the plan has none.
916///
917/// DataFusion's `EnforceDistribution` puts a round-robin repartition under an
918/// operator that benefits from parallel input, such as the `FilterExec` of a
919/// view with a `WHERE` (a subgraph) or the `ProjectionExec` that computes a
920/// weight expression. Draining its partitions in order would then read the
921/// batches out of file order, changing dense ids (and so labels and float
922/// summation order) from what `ursa-io` gives the Python library. This rule
923/// runs after the default rules and removes every round-robin repartition
924/// below a graph node: a round-robin repartition only adds parallelism, so
925/// removing it never changes a result.
926///
927/// What remains may still not preserve order: a join, an aggregate, a hash
928/// repartition, a merge of partitions or a `UNION` produces its rows in an
929/// order that can change between runs and thread counts (#263). Over such a
930/// plan, the rule sorts the edge rows by `(source, destination)`, and a
931/// weighted node's rows by their weights after that, so that rows that tie
932/// are identical in every column the node reads. A traversal's node-id
933/// inputs over such a plan are sorted by value, and so are a graph's vertex
934/// keys, which set the order of its nodes. Order-preserving plans
935/// (scans, views over files, filters, projections) are left as they are,
936/// in file order. The sort is a `SortExec` under the node, so `EXPLAIN`
937/// shows it.
938#[derive(Debug)]
939pub struct KeepEdgeOrder;
940
941impl PhysicalOptimizerRule for KeepEdgeOrder {
942    fn optimize(
943        &self,
944        plan: Arc<dyn ExecutionPlan>,
945        _config: &ConfigOptions,
946    ) -> Result<Arc<dyn ExecutionPlan>> {
947        plan.transform_up(|node| {
948            // The columns each child is sorted by, if its order is not fixed.
949            let keys: Vec<Vec<usize>> =
950                if let Some(exec) = node.downcast_ref::<GraphAlgorithmExec>() {
951                    let mut keys = vec![endpoint_keys(exec.shape.edges.endpoints)];
952                    keys.extend(exec.weighted.as_ref().map(weighted_keys));
953                    keys.extend(exec.shape.edges.vertices.as_ref().map(|_| vec![0]));
954                    // A neighbour aggregation's values give one value per
955                    // node whatever their order: no sort.
956                    keys.extend(exec.values.as_ref().map(|_| vec![]));
957                    keys
958                } else if let Some(exec) = node.downcast_ref::<GraphTraversalExec>() {
959                    let mut keys = vec![endpoint_keys(exec.shape.edges.endpoints)];
960                    keys.extend(exec.weighted.as_ref().map(weighted_keys));
961                    keys.extend(exec.shape.edges.vertices.as_ref().map(|_| vec![0]));
962                    keys.extend(exec.nodes.iter().map(|_| vec![0]));
963                    keys
964                } else if let Some(exec) = node.downcast_ref::<PathSearchExec>() {
965                    let mut keys = vec![endpoint_keys(exec.shape.edges.endpoints)];
966                    keys.extend(exec.shape.edges.vertices.as_ref().map(|_| vec![0]));
967                    keys.push(vec![0]);
968                    keys
969                } else {
970                    return Ok(Transformed::no(node));
971                };
972            let children = node
973                .children()
974                .into_iter()
975                .zip(keys)
976                .map(|(child, keys)| {
977                    let child = without_round_robin(Arc::clone(child))?;
978                    if preserves_order(&child) {
979                        Ok(child)
980                    } else {
981                        sorted(child, &keys)
982                    }
983                })
984                .collect::<Result<Vec<_>>>()?;
985            Ok(Transformed::yes(replace_children_if_necessary(
986                Arc::clone(&node),
987                children,
988            )?))
989        })
990        .map(|t| t.data)
991    }
992
993    fn name(&self) -> &str {
994        "keep_edge_order"
995    }
996
997    fn schema_check(&self) -> bool {
998        true
999    }
1000}
1001
1002/// `plan` with its round-robin repartitions removed.
1003fn without_round_robin(plan: Arc<dyn ExecutionPlan>) -> Result<Arc<dyn ExecutionPlan>> {
1004    plan.transform_up(|node| {
1005        if let Some(repartition) = node.downcast_ref::<RepartitionExec>() {
1006            if matches!(repartition.partitioning(), Partitioning::RoundRobinBatch(_)) {
1007                return Ok(Transformed::yes(Arc::clone(repartition.input())));
1008            }
1009        }
1010        Ok(Transformed::no(node))
1011    })
1012    .map(|t| t.data)
1013}
1014
1015/// The sort key of an edge input: its source, then its destination.
1016fn endpoint_keys((source, destination): (usize, usize)) -> Vec<usize> {
1017    vec![source, destination]
1018}
1019
1020/// The sort key of a weighted edge input: its endpoints, then every weight,
1021/// so rows that tie are the same in every column the node reads.
1022fn weighted_keys(weighted: &WeightedInput) -> Vec<usize> {
1023    let mut keys = endpoint_keys((weighted.source, weighted.destination));
1024    keys.extend(&weighted.weights);
1025    keys
1026}
1027
1028/// Whether reading `plan`'s partitions in order gives its rows in an order
1029/// that does not depend on the run or the thread count: true unless it has
1030/// a join, an aggregate, a repartition, a merge of partitions or a union. A
1031/// nested graph node emits its rows in a fixed order, whatever its inputs.
1032fn preserves_order(plan: &Arc<dyn ExecutionPlan>) -> bool {
1033    let mut preserves = true;
1034    let _ = plan.apply(|node| {
1035        if node.downcast_ref::<GraphAlgorithmExec>().is_some()
1036            || node.downcast_ref::<GraphTraversalExec>().is_some()
1037            || node.downcast_ref::<PathSearchExec>().is_some()
1038        {
1039            return Ok(TreeNodeRecursion::Jump);
1040        }
1041        let reorders = node.downcast_ref::<RepartitionExec>().is_some()
1042            || node.downcast_ref::<CoalescePartitionsExec>().is_some()
1043            || node.downcast_ref::<AggregateExec>().is_some()
1044            || node.downcast_ref::<UnionExec>().is_some()
1045            || node.downcast_ref::<InterleaveExec>().is_some()
1046            || node.name().contains("Join");
1047        if reorders {
1048            preserves = false;
1049            return Ok(TreeNodeRecursion::Stop);
1050        }
1051        Ok(TreeNodeRecursion::Continue)
1052    });
1053    preserves
1054}
1055
1056/// `plan`'s rows sorted by the columns `keys`, ascending with nulls last,
1057/// as one partition: each partition is sorted, then they are merged.
1058fn sorted(plan: Arc<dyn ExecutionPlan>, keys: &[usize]) -> Result<Arc<dyn ExecutionPlan>> {
1059    let schema = plan.schema();
1060    let exprs = keys.iter().map(|&i| {
1061        PhysicalSortExpr::new(
1062            Arc::new(Column::new(schema.field(i).name(), i)) as Arc<dyn PhysicalExpr>,
1063            SortOptions {
1064                descending: false,
1065                nulls_first: false,
1066            },
1067        )
1068    });
1069    let Some(ordering) = LexOrdering::new(exprs) else {
1070        return Ok(plan);
1071    };
1072    let partitions = plan.output_partitioning().partition_count();
1073    let sort =
1074        Arc::new(SortExec::new(ordering.clone(), plan).with_preserve_partitioning(partitions > 1));
1075    Ok(if partitions > 1 {
1076        Arc::new(SortPreservingMergeExec::new(ordering, sort))
1077    } else {
1078        sort
1079    })
1080}
1081
1082/// The index cache key for the edges `input` reads, if they can be cached:
1083/// with a vertex table, the keys' plan and files too, so a change to the
1084/// vertex table builds the index afresh.
1085async fn index_key(
1086    shape: &Edges,
1087    input: &Arc<dyn ExecutionPlan>,
1088    context: &TaskContext,
1089) -> Result<Option<(String, Vec<FileIdentity>)>> {
1090    let Some(edges) = &shape.fingerprint else {
1091        return Ok(None);
1092    };
1093    let mut files = file_identities(input, context).await?;
1094    let fingerprint = match &shape.vertices {
1095        None => edges.clone(),
1096        Some(VertexInput {
1097            fingerprint: None, ..
1098        }) => return Ok(None),
1099        Some(VertexInput {
1100            plan,
1101            fingerprint: Some(vertices),
1102        }) => {
1103            files.extend(file_identities(plan, context).await?);
1104            files.sort();
1105            files.dedup();
1106            format!("{edges}\n-- vertex keys --\n{vertices}")
1107        }
1108    };
1109    Ok(Some((fingerprint, files)))
1110}
1111
1112/// The index, cached or built from the endpoint-only input.
1113async fn unweighted_index(
1114    shape: &Edges,
1115    input: Arc<dyn ExecutionPlan>,
1116    context: &Arc<TaskContext>,
1117) -> Result<Arc<GraphIndex>> {
1118    let build = {
1119        let (shape, input, context) = (shape.clone(), Arc::clone(&input), Arc::clone(context));
1120        async move {
1121            let mut no_weights = WeightReader::new(&[], 0);
1122            build_streamed(&shape, &input, shape.endpoints, &context, &mut no_weights).await
1123        }
1124    };
1125    match context.session_config().get_extension::<IndexCache>() {
1126        Some(cache) => {
1127            let key = index_key(shape, &input, context).await?;
1128            cache
1129                .get_or_build_stored(context.session_config(), key, build.boxed())
1130                .await
1131        }
1132        None => build.await.map(Arc::new),
1133    }
1134}
1135
1136/// The index and the weight columns, for a node that reads weights (see the
1137/// module docs).
1138async fn weighted_index(
1139    shape: &Edges,
1140    input: Arc<dyn ExecutionPlan>,
1141    weighted: WeightedInput,
1142    context: &Arc<TaskContext>,
1143) -> Result<(Arc<GraphIndex>, Weights)> {
1144    let cache = context.session_config().get_extension::<IndexCache>();
1145    let key = match &cache {
1146        Some(_) => index_key(shape, &input, context).await?,
1147        None => None,
1148    };
1149    let weights_key = match &weighted.fingerprint {
1150        Some(f) => Some(format!(
1151            "{f}\n{:?}",
1152            file_identities(&weighted.plan, context).await?
1153        )),
1154        None => None,
1155    };
1156    let n_weights = weighted.weights.len();
1157    // Weights already evaluated over the cached index's rows: nothing to read.
1158    if let (Some(cache), Some(key), Some(weights_key)) = (&cache, &key, &weights_key) {
1159        if let Some(index) = cache.get(key) {
1160            if let Some(weights) = known_weights(&index, weights_key, n_weights).await {
1161                return Ok((index, weights));
1162            }
1163        }
1164    }
1165
1166    let (index, weights, shared) = match &cache {
1167        Some(cache) => {
1168            // Weights read by this node's own build, if the cache ran it.
1169            let slot = Arc::new(Mutex::new(None));
1170            let build = {
1171                let (shape, weighted) = (shape.clone(), weighted.clone());
1172                let (context, slot) = (Arc::clone(context), Arc::clone(&slot));
1173                async move {
1174                    let (index, weights) = build_weighted(&shape, &weighted, &context).await?;
1175                    *slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(weights);
1176                    Ok(index)
1177                }
1178            };
1179            let index = cache
1180                .get_or_build_stored(context.session_config(), key, build.boxed())
1181                .await?;
1182            let built_here = slot.lock().unwrap_or_else(|e| e.into_inner()).take();
1183            // A stored index may have these weights stored with it.
1184            if built_here.is_none() {
1185                if let Some(weights_key) = &weights_key {
1186                    if let Some(weights) = known_weights(&index, weights_key, n_weights).await {
1187                        return Ok((index, weights));
1188                    }
1189                }
1190            }
1191            match built_here {
1192                Some(weights) => (index, weights, true),
1193                None => {
1194                    let mut weights =
1195                        WeightReader::new(&weighted.weights, index.topology.n_edges());
1196                    if read_aligned(&index, &weighted, shape, context, &mut weights).await? {
1197                        (index, weights, true)
1198                    } else {
1199                        // The cached index's rows are in another order: these
1200                        // weights belong to an index of these rows.
1201                        cache.record_build();
1202                        let (private, weights) = build_weighted(shape, &weighted, context).await?;
1203                        (Arc::new(private), weights, false)
1204                    }
1205                }
1206            }
1207        }
1208        None => {
1209            let (index, weights) = build_weighted(shape, &weighted, context).await?;
1210            (Arc::new(index), weights, false)
1211        }
1212    };
1213
1214    let weights: Weights = Arc::new(weights.finish()?.into_iter().map(Arc::new).collect());
1215    if let (true, Some(weights_key)) = (shared, weights_key) {
1216        index.store_weights(weights_key, Arc::clone(&weights));
1217    }
1218    Ok((index, weights))
1219}
1220
1221/// The weights `index` holds under `key` (with `n_weights` columns), in
1222/// memory or stored with it on disk.
1223async fn known_weights(index: &Arc<GraphIndex>, key: &str, n_weights: usize) -> Option<Weights> {
1224    if let Some(weights) = index.weights(key) {
1225        return Some(weights);
1226    }
1227    if !index.has_stored_weights(key) {
1228        return None;
1229    }
1230    let (index, key) = (Arc::clone(index), key.to_string());
1231    // Reading a file is blocking work.
1232    crate::run_blocking(move || index.stored_weights(&key, n_weights))
1233        .await
1234        .ok()
1235        .flatten()
1236}
1237
1238/// Build the index of the weighted rows, reading their weights in the same
1239/// pass, so they line up by construction.
1240async fn build_weighted(
1241    shape: &Edges,
1242    weighted: &WeightedInput,
1243    context: &Arc<TaskContext>,
1244) -> Result<(GraphIndex, WeightReader)> {
1245    let mut weights = WeightReader::new(&weighted.weights, row_hint(&weighted.plan));
1246    let endpoints = (weighted.source, weighted.destination);
1247    let index = build_streamed(shape, &weighted.plan, endpoints, context, &mut weights).await?;
1248    Ok((index, weights))
1249}
1250
1251/// Read the weighted rows once, reading their weights, and say whether
1252/// their endpoints are the rows `index` was built from, row for row. Stops
1253/// at the first row that is not.
1254async fn read_aligned(
1255    index: &GraphIndex,
1256    weighted: &WeightedInput,
1257    shape: &Edges,
1258    context: &Arc<TaskContext>,
1259    weights: &mut WeightReader,
1260) -> Result<bool> {
1261    let id_type = &shape.id_type;
1262    let mut rows = RowCheck::new(index);
1263    let plan = &weighted.plan;
1264    for partition in 0..plan.output_partitioning().partition_count() {
1265        let mut stream = plan.execute(partition, Arc::clone(context))?;
1266        while let Some(batch) = stream.next().await {
1267            let mut batch = batch?;
1268            let mut src = cast(batch.column(weighted.source), id_type)?;
1269            let mut dst = cast(batch.column(weighted.destination), id_type)?;
1270            // With a vertex table, the index holds only the edges between
1271            // keys: skip the others, as the build did.
1272            if shape.vertices.is_some() {
1273                let keep = between_nodes(index, &src, &dst)?;
1274                if keep.true_count() < keep.len() {
1275                    batch = filter_record_batch(&batch, &keep)?;
1276                    src = filter(&src, &keep)?;
1277                    dst = filter(&dst, &keep)?;
1278                }
1279            }
1280            if batch.num_rows() == 0 {
1281                continue;
1282            }
1283            if !rows.check(&src, &dst)? {
1284                return Ok(false);
1285            }
1286            weights.push(&batch);
1287        }
1288    }
1289    Ok(rows.complete())
1290}
1291
1292/// Which edges `src` -> `dst` (of the index's id type) join two nodes of
1293/// `index`.
1294fn between_nodes(index: &GraphIndex, src: &ArrayRef, dst: &ArrayRef) -> Result<BooleanArray> {
1295    let lookup = |a: &ArrayRef| {
1296        index
1297            .ids
1298            .dense_from_array(a.as_ref())
1299            .map_err(|e| DataFusionError::Execution(e.to_string()))
1300    };
1301    let (s, d) = (lookup(src)?, lookup(dst)?);
1302    Ok(s.iter()
1303        .zip(&d)
1304        .map(|(s, d)| Some(s.is_some() && d.is_some()))
1305        .collect())
1306}
1307
1308/// The weight columns of a weighted node's rows, evaluated a batch at a
1309/// time as the rows are read. The first bad weight is reported once every
1310/// row has been read, after any error in building the index from those
1311/// rows, as when the weights were evaluated after the build.
1312struct WeightReader {
1313    columns: Vec<usize>,
1314    values: Vec<Vec<f64>>,
1315    error: Option<DataFusionError>,
1316}
1317
1318impl WeightReader {
1319    /// A reader of the columns `columns`, sized for `rows` rows.
1320    fn new(columns: &[usize], rows: usize) -> Self {
1321        Self {
1322            columns: columns.to_vec(),
1323            values: columns.iter().map(|_| Vec::with_capacity(rows)).collect(),
1324            error: None,
1325        }
1326    }
1327
1328    fn push(&mut self, batch: &RecordBatch) {
1329        if self.error.is_some() {
1330            return;
1331        }
1332        for (&c, values) in self.columns.iter().zip(&mut self.values) {
1333            if let Err(e) = extend_weights(batch.column(c), values) {
1334                self.error = Some(e);
1335                return;
1336            }
1337        }
1338    }
1339
1340    /// Keep only the rows `kept` marks, one bit per row read.
1341    fn keep(&mut self, kept: &BooleanBuffer) {
1342        for values in &mut self.values {
1343            let mut bits = kept.iter();
1344            values.retain(|_| bits.next().unwrap_or(false));
1345        }
1346    }
1347
1348    /// One `f64` per row read, per column, or the first bad weight.
1349    fn finish(self) -> Result<Vec<Vec<f64>>> {
1350        match self.error {
1351            Some(e) => Err(e),
1352            None => Ok(self.values),
1353        }
1354    }
1355}
1356
1357/// Every batch of `input`, partition by partition in order, as the columns
1358/// `columns` of each non-empty batch.
1359async fn drain(
1360    input: &Arc<dyn ExecutionPlan>,
1361    columns: &[usize],
1362    context: &Arc<TaskContext>,
1363) -> Result<Vec<Vec<ArrayRef>>> {
1364    let mut out = Vec::new();
1365    let partitions = input.output_partitioning().partition_count();
1366    for partition in 0..partitions {
1367        let mut stream = input.execute(partition, Arc::clone(context))?;
1368        while let Some(batch) = stream.next().await {
1369            let batch = batch?;
1370            if batch.num_rows() == 0 {
1371                continue;
1372            }
1373            out.push(
1374                columns
1375                    .iter()
1376                    .map(|&c| Arc::clone(batch.column(c)))
1377                    .collect(),
1378            );
1379        }
1380    }
1381    Ok(out)
1382}
1383
1384/// Rows the index build interns at a time. A scan's batches are 8,192 rows,
1385/// and `EdgeInterner` resolves known ids in parallel only in larger chunks,
1386/// so the build gathers batches up to this many rows first: what `ursa-io`'s
1387/// scan does for the Python library (its `SCAN_BATCH_ROWS`). A chunk is
1388/// 2 MiB of `Int64` endpoints.
1389const INTERN_ROWS: usize = 1 << 17;
1390
1391/// Endpoint chunks gathered up to [`INTERN_ROWS`] rows, in row order.
1392#[derive(Default)]
1393struct Gather {
1394    src: Vec<ArrayRef>,
1395    dst: Vec<ArrayRef>,
1396    rows: usize,
1397}
1398
1399impl Gather {
1400    /// Add a chunk; once [`INTERN_ROWS`] rows are gathered, take them.
1401    fn push(&mut self, src: ArrayRef, dst: ArrayRef) -> Result<Option<(ArrayRef, ArrayRef)>> {
1402        self.rows += src.len();
1403        self.src.push(src);
1404        self.dst.push(dst);
1405        if self.rows >= INTERN_ROWS {
1406            self.take()
1407        } else {
1408            Ok(None)
1409        }
1410    }
1411
1412    /// The rows gathered so far, as one chunk, if there are any.
1413    fn take(&mut self) -> Result<Option<(ArrayRef, ArrayRef)>> {
1414        self.rows = 0;
1415        let (src, dst) = (std::mem::take(&mut self.src), std::mem::take(&mut self.dst));
1416        Ok(match src.len() {
1417            0 => None,
1418            1 => src.into_iter().zip(dst).next(),
1419            _ => {
1420                let join = |arrays: &[ArrayRef]| {
1421                    let refs: Vec<&dyn Array> = arrays.iter().map(|a| a.as_ref()).collect();
1422                    concat(&refs)
1423                };
1424                Some((join(&src)?, join(&dst)?))
1425            }
1426        })
1427    }
1428}
1429
1430/// The index of everything `interner` was given; an empty graph of
1431/// `id_type` ids if it was given no node and no edge.
1432fn finish_index(mut interner: EdgeInterner, id_type: &DataType) -> GraphIndex {
1433    let (n_dropped, kept) = (interner.n_dropped(), interner.kept_rows());
1434    let (ids, source, destination) = interner.finish();
1435    if ids.is_empty() {
1436        let (topology, ids) = ursa_io::topology::empty_topology(id_type);
1437        return GraphIndex::new(topology, ids).with_dropped(n_dropped, kept);
1438    }
1439    let topology = Topology::build(ids.len(), source, destination);
1440    GraphIndex::new(Arc::new(topology), Arc::new(ids)).with_dropped(n_dropped, kept)
1441}
1442
1443/// The number of rows `plan` will produce, if its statistics say: sizes the
1444/// interner, as `ursa-io`'s scan sizes it from the Parquet metadata.
1445fn row_hint(plan: &Arc<dyn ExecutionPlan>) -> usize {
1446    StatisticsContext::new()
1447        .compute(plan.as_ref(), &StatisticsArgs::new())
1448        .ok()
1449        .and_then(|s| s.num_rows.get_value().copied())
1450        .unwrap_or(0)
1451}
1452
1453/// What the reader hands the interning thread.
1454enum Chunk {
1455    /// Vertex keys, before any edge.
1456    Nodes(ArrayRef),
1457    /// Every vertex key has been read: the node set is fixed.
1458    NodesDone,
1459    Edges(ArrayRef, ArrayRef),
1460    /// Every row has been read: build the index.
1461    End,
1462}
1463
1464/// Hand one chunk to `interner` (see [`build_streamed`]); the index once
1465/// every row has been read.
1466fn take_chunk(
1467    interner: &mut EdgeInterner,
1468    chunk: Chunk,
1469    graph: &str,
1470    id_type: &DataType,
1471) -> Result<Option<GraphIndex>> {
1472    match chunk {
1473        Chunk::Nodes(keys) => interner
1474            .push_nodes(keys.as_ref())
1475            .map_err(|e| build_error(graph, e))?,
1476        Chunk::NodesDone => interner.close_nodes(),
1477        Chunk::Edges(src, dst) => interner
1478            .push(src.as_ref(), dst.as_ref())
1479            .map_err(|e| build_error(graph, e))?,
1480        Chunk::End => {
1481            let interner = std::mem::replace(interner, EdgeInterner::with_capacity(0));
1482            return Ok(Some(finish_index(interner, id_type)));
1483        }
1484    }
1485    Ok(None)
1486}
1487
1488/// Build the index of the edge rows `input` produces, interning each chunk
1489/// as it is read rather than collecting every endpoint array first, so the
1490/// Arrow input never accumulates: the build holds the dense endpoints and a
1491/// chunk or two in flight, as `ursa-io`'s scan does. Reading (here) and
1492/// interning (on a blocking thread) overlap. The rows are interned in the
1493/// order [`drain`] would collect them, so the index is the one
1494/// `ursa_io::build_topology_batches` gives over the collected rows. A
1495/// weighted node's weights are read from the same rows (`weights`), and with
1496/// a vertex table kept only for the edges kept (see the module docs), whose
1497/// keys are read first.
1498async fn build_streamed(
1499    shape: &Edges,
1500    input: &Arc<dyn ExecutionPlan>,
1501    (source, destination): (usize, usize),
1502    context: &Arc<TaskContext>,
1503    weights: &mut WeightReader,
1504) -> Result<GraphIndex> {
1505    let (graph, id_type) = (shape.graph.clone(), shape.id_type.clone());
1506    let hint = row_hint(input);
1507    let (tx, mut rx) = tokio::sync::mpsc::channel::<Chunk>(2);
1508
1509    // Native: the interner runs on a blocking thread, so reading and interning
1510    // overlap on two threads. It is spawned here, before the reader starts
1511    // (`run_blocking` would start it only when awaited, after the reader).
1512    #[cfg(not(target_arch = "wasm32"))]
1513    let interning = tokio::task::spawn_blocking(move || -> Result<Option<GraphIndex>> {
1514        let mut interner = EdgeInterner::with_capacity(hint);
1515        while let Some(chunk) = rx.blocking_recv() {
1516            if let Some(index) = take_chunk(&mut interner, chunk, &graph, &id_type)? {
1517                return Ok(Some(index));
1518            }
1519        }
1520        // The reader stopped before the end (its error is the one reported).
1521        Ok(None)
1522    });
1523    // The browser (wasm32) has one thread: the interner is a future polled
1524    // alongside the reader, taking each chunk as it is sent. The channel's
1525    // bound still keeps at most two chunks in flight, and the chunks arrive in
1526    // the same order, so the index is the same (#278).
1527    #[cfg(target_arch = "wasm32")]
1528    let interning = async move {
1529        let mut interner = EdgeInterner::with_capacity(hint);
1530        while let Some(chunk) = rx.recv().await {
1531            if let Some(index) = take_chunk(&mut interner, chunk, &graph, &id_type)? {
1532                return Ok(Some(index));
1533            }
1534        }
1535        Ok::<_, DataFusionError>(None)
1536    };
1537
1538    let read = async {
1539        // The reader owns the sender, so the interner sees the channel close
1540        // when the reader stops, on an error as at the end.
1541        let tx = tx;
1542        if let Some(vertices) = &shape.vertices {
1543            let keys = &vertices.plan;
1544            for partition in 0..keys.output_partitioning().partition_count() {
1545                let mut stream = keys.execute(partition, Arc::clone(context))?;
1546                while let Some(batch) = stream.next().await {
1547                    let batch = batch?;
1548                    if batch.num_rows() == 0 {
1549                        continue;
1550                    }
1551                    let keys = cast(batch.column(0), &shape.id_type)?;
1552                    if tx.send(Chunk::Nodes(keys)).await.is_err() {
1553                        return Ok(());
1554                    }
1555                }
1556            }
1557            if tx.send(Chunk::NodesDone).await.is_err() {
1558                return Ok(());
1559            }
1560        }
1561        let mut gather = Gather::default();
1562        for partition in 0..input.output_partitioning().partition_count() {
1563            let mut stream = input.execute(partition, Arc::clone(context))?;
1564            while let Some(batch) = stream.next().await {
1565                let batch = batch?;
1566                if batch.num_rows() == 0 {
1567                    continue;
1568                }
1569                let src = cast(batch.column(source), &shape.id_type)?;
1570                let dst = cast(batch.column(destination), &shape.id_type)?;
1571                weights.push(&batch);
1572                drop(batch);
1573                if let Some((src, dst)) = gather.push(src, dst)? {
1574                    if tx.send(Chunk::Edges(src, dst)).await.is_err() {
1575                        // The interner stopped on an error: it is reported below.
1576                        return Ok(());
1577                    }
1578                }
1579            }
1580        }
1581        if let Some((src, dst)) = gather.take()? {
1582            if tx.send(Chunk::Edges(src, dst)).await.is_err() {
1583                return Ok(());
1584            }
1585        }
1586        let _ = tx.send(Chunk::End).await;
1587        Ok::<_, DataFusionError>(())
1588    };
1589    #[cfg(not(target_arch = "wasm32"))]
1590    let (read, built) = {
1591        let read = read.await;
1592        let built = interning.await.map_err(|e| {
1593            DataFusionError::Execution(format!("building graph '{}' failed: {e}", shape.graph))
1594        })?;
1595        (read, built)
1596    };
1597    #[cfg(target_arch = "wasm32")]
1598    let (read, built) = futures::join!(read, interning);
1599    read?;
1600    let index = built?.ok_or_else(|| {
1601        DataFusionError::Internal(format!(
1602            "building graph '{}' stopped before the last edge",
1603            shape.graph
1604        ))
1605    })?;
1606    if let Some(kept) = index.kept_rows() {
1607        weights.keep(kept);
1608    }
1609    Ok(index)
1610}
1611
1612/// Checks, a chunk at a time, that rows are the edge rows an index was
1613/// built from, in order.
1614struct RowCheck<'a> {
1615    index: &'a GraphIndex,
1616    /// Each row's endpoints as dense ids, from the CSR and its row
1617    /// permutation.
1618    source: Vec<u32>,
1619    destination: Vec<u32>,
1620    /// Rows checked so far.
1621    start: usize,
1622}
1623
1624impl<'a> RowCheck<'a> {
1625    fn new(index: &'a GraphIndex) -> Self {
1626        let topology = &index.topology;
1627        let n = topology.n_edges();
1628        let out = topology.out();
1629        let (mut source, mut destination) = (vec![0u32; n], vec![0u32; n]);
1630        for u in 0..topology.n_nodes() {
1631            let slots = out.offsets[u] as usize..out.offsets[u + 1] as usize;
1632            for slot in slots {
1633                let row = out.edge_ids[slot] as usize;
1634                source[row] = u as u32;
1635                destination[row] = out.targets[slot];
1636            }
1637        }
1638        Self {
1639            index,
1640            source,
1641            destination,
1642            start: 0,
1643        }
1644    }
1645
1646    /// Whether the next rows are `src` → `dst`. Each chunk's ids are
1647    /// compared on their own, so no column of every row's ids is made.
1648    fn check(&mut self, src: &ArrayRef, dst: &ArrayRef) -> Result<bool> {
1649        let rows = self.start..self.start + src.len();
1650        if rows.end > self.source.len() {
1651            return Ok(false);
1652        }
1653        self.start = rows.end;
1654        let ids = &self.index.ids;
1655        let user_type = ids.user_type();
1656        Ok(
1657            ids.gather_user(&self.source[rows.clone()]).as_ref() == cast(src, &user_type)?.as_ref()
1658                && ids.gather_user(&self.destination[rows]).as_ref()
1659                    == cast(dst, &user_type)?.as_ref(),
1660        )
1661    }
1662
1663    /// Whether every row of the index has been checked.
1664    fn complete(&self) -> bool {
1665        self.start == self.source.len()
1666    }
1667}
1668
1669/// Whether `endpoints` are the edge rows `index` was built from, row for row.
1670#[cfg(test)]
1671fn aligned(index: &GraphIndex, endpoints: &[(ArrayRef, ArrayRef)]) -> Result<bool> {
1672    let mut rows = RowCheck::new(index);
1673    for (src, dst) in endpoints {
1674        if !rows.check(src, dst)? {
1675            return Ok(false);
1676        }
1677    }
1678    Ok(rows.complete())
1679}
1680
1681/// A weight expression's values, one `f64` per edge row, checked as
1682/// `ursa-plan`'s `evaluate_weight` checks them, with its messages.
1683#[cfg(test)]
1684fn edge_weights(columns: &[ArrayRef]) -> Result<Vec<f64>> {
1685    let mut out = Vec::with_capacity(columns.iter().map(|c| c.len()).sum());
1686    for column in columns {
1687        extend_weights(column, &mut out)?;
1688    }
1689    Ok(out)
1690}
1691
1692/// Append one chunk of a weight expression's values to `out`, checked as
1693/// `ursa-plan`'s `evaluate_weight` checks them, with its messages.
1694fn extend_weights(column: &ArrayRef, out: &mut Vec<f64>) -> Result<()> {
1695    let column = cast(column, &DataType::Float64)?;
1696    let column = column
1697        .as_any()
1698        .downcast_ref::<Float64Array>()
1699        .expect("cast to Float64 yields Float64Array");
1700    if column.null_count() > 0 {
1701        return Err(DataFusionError::Execution(
1702            "weight expression produced a null value; weights must be non-null".into(),
1703        ));
1704    }
1705    let values = column.values();
1706    if let Some(&w) = values.iter().find(|&&w| w < 0.0) {
1707        return Err(DataFusionError::Execution(format!(
1708            "weight expression produced a negative value ({w}); weights must be \
1709             non-negative"
1710        )));
1711    }
1712    out.extend_from_slice(values);
1713    Ok(())
1714}
1715
1716fn build_error(graph: &str, e: IdError) -> DataFusionError {
1717    DataFusionError::Execution(match e {
1718        IdError::Null => format!(
1719            "graph '{graph}': an edge has a null source or destination; filter those rows out \
1720             of the edge table (for example, with a view that keeps rows WHERE both are NOT NULL)"
1721        ),
1722        other => format!("graph '{graph}': {other}"),
1723    })
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728    use super::*;
1729    use datafusion::arrow::array::{Int64Array, StringArray};
1730
1731    fn index(src: &dyn Array, dst: &dyn Array) -> GraphIndex {
1732        let (topology, ids) = ursa_io::build_topology_batches(&[(src, dst)]).unwrap();
1733        GraphIndex::new(topology, ids)
1734    }
1735
1736    fn ints(v: &[i64]) -> ArrayRef {
1737        Arc::new(Int64Array::from(v.to_vec()))
1738    }
1739
1740    #[test]
1741    fn weights_line_up_only_with_the_rows_the_index_was_built_from() {
1742        let (src, dst) = (ints(&[10, 20, 10, 30]), ints(&[20, 30, 30, 10]));
1743        let built = index(src.as_ref(), dst.as_ref());
1744        // The same rows, in two batches: aligned.
1745        let split = vec![
1746            (src.slice(0, 1), dst.slice(0, 1)),
1747            (src.slice(1, 3), dst.slice(1, 3)),
1748        ];
1749        assert!(aligned(&built, &split).unwrap());
1750        // The same edge set in another order: not.
1751        let swapped = vec![(ints(&[20, 10, 10, 30]), ints(&[30, 20, 30, 10]))];
1752        assert!(!aligned(&built, &swapped).unwrap());
1753        // Fewer rows: not.
1754        assert!(!aligned(&built, &split[1..]).unwrap());
1755
1756        let names = |v: &[&str]| Arc::new(StringArray::from(v.to_vec())) as ArrayRef;
1757        let (s, d) = (names(&["a", "b"]), names(&["b", "c"]));
1758        let built = index(s.as_ref(), d.as_ref());
1759        assert!(aligned(&built, &[(Arc::clone(&s), Arc::clone(&d))]).unwrap());
1760        assert!(!aligned(&built, &[(d, s)]).unwrap());
1761    }
1762
1763    #[test]
1764    fn weights_are_checked_as_ursa_plan_checks_them() {
1765        let ok = edge_weights(&[ints(&[1, 2]), ints(&[0])]).unwrap();
1766        assert_eq!(ok, vec![1.0, 2.0, 0.0]);
1767        let negative = edge_weights(&[ints(&[1, -1])]).unwrap_err();
1768        assert_eq!(
1769            crate::user_message(&negative),
1770            "weight expression produced a negative value (-1); weights must be non-negative"
1771        );
1772        let null: ArrayRef = Arc::new(Int64Array::from(vec![Some(1), None]));
1773        assert_eq!(
1774            crate::user_message(&edge_weights(&[null]).unwrap_err()),
1775            "weight expression produced a null value; weights must be non-null"
1776        );
1777    }
1778}