Skip to content

Optimizer rules and rewrites

lance-csr changes plans at two levels: graph rewrites before the plan reaches DataFusion, and DataFusion optimizer rules that understand the custom operators. It also gives DataFusion the join statistics it needs for the hops that stay relational.

The rules, rewrites and statistics on this page were added in lance-csr. Unrolling a single-type variable-length path into one branch per length came from lance-graph; lance-csr added the iterative traversal that replaces it when the CSR covers the path.

Logical optimizer rules

DataFusion's own optimizer treats extension nodes as opaque, so two OptimizerRules fill the gaps.

PushCsrTargetFilters

DataFusion can push a filter below a join, but not into an extension node. This rule splits a Filter above a CsrExpand into its conjuncts, looking through column-only projections that projection pushdown may have left between them, and moves:

  • conjuncts that read only the fused target's columns into the node's target filter;
  • conjuncts that read only the named relationship's own columns into its relationship filter.

Conjuncts that mix variables, or are volatile, stay where they were. The operator then reads just the filter's columns for each batch of candidates, evaluates the predicate, and fetches the remaining columns only for rows that pass. Evaluating the filter once over the whole target table was tried first; it cost more than it saved whenever the expansion was already small, which re-rooting usually ensures.

PruneCsrTargetColumns

DataFusion's projection pushdown prunes an extension node's inputs but not its outputs, so without this rule a fused lookup would fetch every target column for every row. The rule collects the columns used anywhere in the plan and narrows each CsrExpand's target and relationship columns, and each NodeLookup, to that set. A NodeLookup whose columns are all unused is removed.

"Used" is conservative: expression references, the plan's output columns, each CSR node's key and closing column, and all inputs of nodes whose column use is implicit, such as DISTINCT and UNION.

Why the order matters

PushCsrTargetFilters runs first. After a filter moves into the node, the columns only the filter reads are no longer referenced above it, so PruneCsrTargetColumns can drop them from the output. The logical plan (before the rules) and the physical plan (after them) of one hop show both:

Text Only
MATCH (a:Person {id: 1})-[k:KNOWS]->(b:Person) WHERE k.since > 2020 AND b.age > 35 RETURN b.name

logical:  CsrExpand: key=a__id -> [k__src, k__dst], edges=4, take=b[b__id*, b__name, b__age], edge=k[k__since]
physical: CsrExpandExec: key=a__id, edges=4, take=b[b__name], edge=k[], filter=age@0 > 35, edge_filter=since@0 > 2020

Graph rewrites before DataFusion sees the plan

Re-rooting: start at the most selective node

The logical planner scans a path's first node and expands left to right, so MATCH (f:Forum)-[:hasMember]->(p:Person) WHERE p.id = 42 would expand every forum's memberships before filtering. The join path partly recovers because DataFusion pushes the filter below the join; a CSR expansion can't. So pattern_order.rs rewrites each MATCH into an equivalent traversal that starts at the best-scoring node (one with a key or property filter), walking segments backwards where needed: an outgoing edge walked from its target is incoming.

flowchart LR
    subgraph before ["As written"]
        direction LR
        F1["(f:Forum)<br/>every forum"] -->|hasMember| P1["(p:Person)<br/>WHERE p.id = 42"]
    end
    subgraph after ["Re-rooted"]
        direction LR
        P2["(p:Person {id: 42})<br/>one row"] -->|"hasMember, incoming"| F2["(f:Forum)"]
    end
    before ~~~ after

The same edges, endpoints, labels and variables are matched, and the full WHERE clause still applies above the match. Only the order of work changes.

Alternation and multi-label nodes become branches

-[:A|B]-> and (m:Post:Comment) are rewritten into branches with exactly one type per relationship and at most one label per node, combined with UNION ALL. Only combinations consistent with the configured endpoint labels are kept, and each branch is an ordinary pattern, so each keeps the CSR and re-rooting.

A variable-length segment with several types, such as -[:hasType|isSubclassOf*1..20]->, runs as one typed iterative traversal when every type and label is indexed. Otherwise it is unrolled into the sequences of types whose endpoint labels chain, and each sequence is branched the same way.

Combinations that the configuration can't pin down are an error rather than a guess: joining an edge type to the wrong node table silently matches wrong rows whenever two labels share ids.

Join statistics for graph workloads

Not every hop runs on the CSR, and the joins that remain need good cardinality estimates. DataFusion estimates an equi-join's output as L × R / max(NDV_l, NDV_r), using the number of distinct values (NDV) of each join key. Neither MemTable nor LanceTableProvider reports distinct counts, so DataFusion substitutes the row count. For a graph join chain, that turns a multi-million-row intermediate into an estimate of a few thousand rows, under the 128k-row CollectLeft threshold, and DataFusion then builds that intermediate as one single-threaded hash table.

KeyStatisticsProvider wraps each table and reports distinct counts for its node-id and edge-endpoint columns, taken from the CSR when there is one and otherwise from one scan of the endpoint columns. Every other statistic comes from the inner table. The counts are cached per dataset version, so a refresh() with no changes costs nothing.