Skip to content

feat(scanner): plan every read through the logical planner - #8561

Closed
wjones127 wants to merge 28 commits into
lance-format:mainfrom
wjones127:feat/logical-scan-planner-default
Closed

feat(scanner): plan every read through the logical planner#8561
wjones127 wants to merge 28 commits into
lance-format:mainfrom
wjones127:feat/logical-scan-planner-default

Conversation

@wjones127

Copy link
Copy Markdown
Contributor

#8557 added a logical-plan read path behind LANCE_LOGICAL_SCAN_PLANNER=1, off by default, next to the imperative Scanner::create_plan. This makes it the only read path: the flag is gone, and with it the hand-written physical builder — about 3,200 lines of scanner.rs, including the (nearest, full_text_query) dispatch, the vector and FTS source builders, the take and prefilter plumbing, and the limit pushdown that the logical Limit now expresses. The legacy (v1) exec nodes stay, since the logical path calls them for legacy storage.

The bulk of the diff is tests, not deletion. Most of the planner's tests planned a query both ways and compared the results, which says nothing once there is only one way. They now state what the query's answer is: each fixture derives its data from the row's i, so the expected rows can be computed in Rust and compared against what the scan returned, column values included. Approximate search asserts a recall floor against a brute-force oracle instead, and the tests that pin a plan's shape keep doing that.

Flipping the default also brought out the last piece of parity the flagged path was missing: when a prefilter's predicate is answered exactly by a scalar index, the search now consumes the index lookup itself instead of a read of the rows it selects, as prefilter_source did.

Two behavior changes are worth a look:

  • Full-text results are ordered by _score descending, then _rowid ascending, stated as a sort above the take rather than inherited from whichever operator happened to sort last. The tie-break is new: relevance ties previously came out in whatever order the FTS operators produced, which was not the same for a query with a limit and the same query without one.
  • A search with a postfilter reads the filter's columns and the output columns in one take, where the imperative path used two, and a small ProjectionExec sits above each search to put _rowid and _distance in the order the take expects. Same rows, same reads.

Two changes outside the scanner come along because the new path reaches them:

  • SimplifyProjection now folds a column-only projection into the projection below it, and re-examines the result, so the stacked projections the lowering produces collapse instead of surviving into the executed plan.
  • row_ids_for_mask computed a Vec capacity by subtracting the block list's length from the row count, which underflows when a plan materializes an index over a subset of fragments the block list ranges beyond.

Not included

LanceTableProvider still plans through Scanner::create_plan rather than being a thin wrapper over LanceScanSource. Consolidating the two needs somewhere for the legacy-storage escape hatch to live — the v1 leaf hands the whole Scanner to the frozen legacy builder, so the provider cannot stop building one — and it changes a public provider's pushdown behavior, which is worth its own PR.

Stacked on #8557.

wjones127 and others added 28 commits August 15, 2026 20:05
Fragment readers build batches from the projected field list, which carries
no schema-level metadata, so batches arrived without the dataset's metadata
even though `output_schema` declares it. Callers ending in a projection
re-stamp it on the way out and never notice; callers reading this node's
output directly saw empty metadata.

Restamp in both `get_stream` branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In take mode the node reads one output row per input row, in input order, so
its input's ordering holds on its output. It inherited the input's
`PlanProperties` — partitioning and emission — but then replaced the
equivalence properties wholesale, dropping the ordering.

Re-express the input's ordering against the output schema, whose column
positions differ, stopping the prefix at the first expression the output
schema cannot name. Scan mode is unchanged: it is a leaf with no input
ordering to carry.

No plan changes: the physical rule set reads equivalence properties only in
EnforceDistribution, which does not repartition below a take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prototype of the "DataFusion Logical Plans for the Lance Scanner" design.
`Scanner::create_plan` builds physical plans imperatively today. This adds an
alternative path that builds a `LogicalPlan`, prefetches index and fragment
metadata into a `ScanPlanningContext`, derives optimizer rules from that
context, and lowers via an `ExtensionPlanner`.

Off by default; enabled with `LANCE_LOGICAL_SCAN_PLANNER=1`. The only change
outside the new module is a 5-line dispatch in `Scanner::create_plan`.
Unsupported query shapes return `NotSupported` rather than falling back
silently, so the equivalence tests stay meaningful.

Covers filtered scan + projection + limit, flat KNN, ANN, prefilter,
postfilter, and combined KNN/ANN over partially-indexed data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports every FTS query shape reachable from `Scanner::create_plan` to the
logical-plan prototype: Match, Phrase, Boost, Boolean, MultiMatch, row and
list-element granularity, index and flat access paths, partially-indexed
splits, fast_search, fragment restriction, and both directions of
`query_filter` in prefilter and postfilter form.

All FTS support lives in one file (`logical/fts.rs`) exposing five entry
points — nodes, prefetch, rules, lowering, requirement collection — as an
experiment in what an index plugin's planning surface would look like.

Three structural changes to the existing stages:

- A new stage 0 (`logical/prepare.rs`) normalizes the scanner's queries
  before the plan is built. An `FtsQuery` may omit its column and document
  granularity, and resolving those needs I/O that stage 1 cannot do and a
  rule cannot defer, since node output schemas depend on granularity.
- Metadata-dependent input validation moved into stage 2. An error raised
  from `OptimizerRule::rewrite` returns wrapped as `DataFusionError::External`
  and loses the Lance `Error` variant callers match on.
- `TakeSettings` carries the scanner's fragment list and batch size to every
  take, including takes a rule creates. `with_fragments` reaches a search
  only through the take's read options.

Also adds `VectorRerankNode` for a postfilter vector `query_filter`, and
prepends DataFusion's `JoinSelection` to the physical rule set: the stock
planner emits `HashJoinExec` with `PartitionMode::Auto`, which panics at
execute() without it.

45 tests, 35 of which execute both paths and compare rows.
Closes the shapes where the logical path returned wrong rows rather than an
error, which is what makes it usable as an oracle:

- `ordering`: sort below limit/offset, ordering columns unioned into the take.
  This was rejecting ~59 tests as unsupported, hiding real failures behind noise.
- `refine_factor`: the over-fetch was already there, only the exact re-rank was
  missing. `ExpandVectorRefine` builds the same subtree
  `SplitPartiallyIndexedSearch` does — "re-rank approximate candidates exactly"
  is one logical operation with two reasons to reach for it.
- `fast_search`: one feature, three manifestations — no index at all becomes
  `EmptyRelation`, a scalar index filter sets `only_indexed_fragments`, partial
  coverage skips the flat branch.
- Flat KNN lost global distance order at parallelism > 1: `FilteredReadExec`
  advertises no output ordering, so the take above the top-k made `execute_plan`
  pick a plain coalesce. Fixed by stating the ordering in the logical plan.
- `_rowcreatedatversion` / `_rowlastupdatedatversion` projections.
- ANN delta segments covering none of the scan's fragments are pruned.

Also prepends `JoinSelection` to the physical rules: `get_physical_optimizer` is
tuned for hand-built plans, and `DefaultPhysicalPlanner` emits a `HashJoinExec`
with `PartitionMode::Auto`, which panics at `execute()` without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SplitPartiallyIndexedSearch` and `SplitPartiallyIndexedFts` were the same
rewrite twice: split a search on how much of the scan its index covers, answer
the covered part from the index and the rest by brute force, merge. Data
overlays are the same statement at row granularity — an overlay committed after
an index was built leaves the index describing values that no longer exist, so
those rows are a coverage gap too.

Both rules are replaced by one `SplitOnIndexCoverage` plus a `SplittableSearch`
trait. What counts as coverage and how the branches merge stay per-index-kind;
the split does not. Overlay stale rows need no machinery of their own — they are
one more entry in the gap list, narrowing that branch's scan by row set instead
of by fragment, and the same union absorbs them.

Two things fell out of the unification:

* `fast_search` becomes one statement instead of three. `IndexCoverage::
  indexed_only()` drops the brute-force branch but keeps the block mask: "answer
  from indices only" means a gap goes unanswered, and a stale entry is not an
  answer either.
* The one legitimate difference between index kinds is now a parameter. A vector
  or scalar index can be blocked by row address whatever segment produced it, so
  an opaque segment is `Covering`; a BM25 score depends on the whole indexed
  document set, so for FTS it is `Opaque` and a relevant overlay makes the index
  `Unusable`.

The rule reaches two of the three cases. A scalar index query is not a node in
the logical plan — it is derived in the scan leaf from pushed-down predicates —
so its split lives in `LanceScanSource::scan_impl`. Unifying that one means
giving the index query a logical node, which is beyond this spike; the finding
is recorded on `stale_rows_branch`.

`ensure_supported` no longer rejects overlaid datasets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rows_as_index_input` mirrored `Scanner::row_ids_as_take_input` and wrapped the
serialized mask in a `OneShotExec`. The mask is a materialized batch, so a
memory source expresses the same thing without the one-shot restriction.

This does not make the scan re-executable, and an earlier note claiming that a
row-restricted scan was single-execution while a fragment-restricted one was not
is wrong. Measured on a plain filtered scan with no index and no overlay, both
planning paths return the full result once and zero rows on a second
`execute()`: `FilteredReadStream` drains one shared `task_stream` per instance.
Single-execution is a property of the leaf, not of how the leaf was narrowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DataFusion documents an `OptimizerRule` as computing "the same results, but
in a potentially more efficient way", and directs semantics-changing rewrites to
`AnalyzerRule`. Measured against that, most of the spike's rules were misfiled:
skip access-path resolution or the coverage split and a search silently returns
the wrong rows.

`ResolveVectorAccessPath`, `ResolveFtsAccessPath`, `SplitOnIndexCoverage`, and
`ExpandVectorRefine` become analyzer rules. `UseFtsCompoundScorer` stays an
optimizer rule — it is the only one that fits the trait's contract, since
dropping it still returns the same rows.

Two things follow. The analyzer runs each rule exactly once, where the optimizer
re-runs the list until the plan stops changing, so the marker fields that existed
only so a rule could recognize its own output are gone: `input_fully_indexed`,
`refine_expanded`, and their `PartialEq`/`Hash` plumbing. And the analyzer checks
`InvariantLevel::Executable` after its last rule, so `VectorSearchNode` and
`FtsLeafNode` now reject an unresolved access path there — a rule that fails to
fire is a planning error instead of a silent brute-force fallback.

`ResolvePrefilterSource` stays in the optimizer. It is equally mandatory but
answers "is there a predicate below the search?", which `PushDownFilter` is what
settles, so it cannot run in a stage that precedes the optimizer.

The `resolution.is_some()` checks in the two resolvers are kept and documented:
they read like idempotence guards but are not. The builder resolves a search to
`Flat` when its input already is the candidate set, and that is a decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scalar index query was derived inside `TableProvider::scan`, so no rule
could see it and its overlay coverage split had to be written by hand as an
imperative `UnionExec` in `scan_impl`. Add a `filter_plan` slot to
`LanceScanSource` with a public accessor, fill it from a new
`ResolveScalarIndexQuery` rule, and recognize a resolved scan as one more
`SplittableSearch`. All three coverage splits now go through
`SplitOnIndexCoverage`.

The scan half must observe `PushDownFilter`'s output, so the rule is registered
in both stages, parameterized by `SplitScope`: searches in the analyzer, scans
in the optimizer, before `PushDownLimit`. Being an optimizer rule, the scan half
re-fires, so it is guarded by the block its own output carries.

`fast_search` moves to `SplittableSearch::honors_fast_search` — it may drop a
search's brute-force branch but not a scan's overlay re-read, which repairs the
index result rather than extending it.

Fixes `test_btree_overlay_row_level_precision` and
`test_btree_overlay_masked_under_fast_search` under the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes out the spike's remaining risk items.

Guard list is now compiler-enforced: `ensure_supported` destructures `Scanner`
exhaustively, so adding a field fails to compile until someone decides what
this path does with it. That surfaced three shapes the path had been silently
ignoring rather than rejecting — explicit `materialization_style`, a projection
of only dynamic expressions, and a `_rowoffset` predicate.

Oracle now compares rows in order by default. 22 of 24 unordered call sites
were defensive; the 4 that remain are an all-ties FTS score sort and three
coverage-split unions, each with its reason at the call site.

Adds `benches/logical_scan_planner.rs`, comparing planning latency and
execution for scan-heavy and search-heavy shapes. Execution is within 1% on
filtered scans; planning is 2-4x more expensive.

`SessionState` is built from the session `execute_plan` will run the plan on,
rather than a bare `SessionConfig`, so lowering sees the runtime it will
execute against. This removes the intermittent `ResourcesExhausted` failures
under parallel test load.

Also fixes a pre-existing bug in `FilteredReadExec`: it declared an
`output_schema` carrying the dataset's schema metadata but its fragment readers
emitted batches without it. Callers that end in a projection re-stamp the
metadata and never notice; callers that emit the node's output directly do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Planning built a `SessionState` per query, which re-populated DataFusion's
entire function catalog via `with_default_features()` to plan a scan that
consults none of it. That was ~37us per query — more than the imperative path
spends building a whole plan for a trivial scan.

It looked structural: each Lance rule holds an `Arc<ScanPlanningContext>`, so
the rule list varies per query, and registering rules is what `SessionState`
is for. But the rules never needed to be on the state. `Analyzer::with_rules`
and `Optimizer::with_rules` are standalone, and `SessionState` already
implements `OptimizerConfig` — `SessionState::optimize` itself is just those
two calls. So the state now carries only what is query-independent (config,
runtime env, catalog, Lance's physical rules) and is cached on
`(session_id, target_partitions)`, while the rule lists are supplied per call.

Planning, before -> after, against the imperative path:

  full_scan                 64us -> 29us   (imperative 28us)
  filtered_scan            115us -> 72us   (imperative 55us)
  filtered_scan_with_limit 138us -> 99us   (imperative 84us)
  ann                      234us -> 198us  (imperative 64us)
  ann_prefiltered          332us -> 279us  (imperative 98us)

The saving is 35-53us and near-constant across shapes, which is the signature
of a fixed cost removed. Plain scans now plan at parity. The residual gap on
searches is shape-dependent rule cost and is unaddressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fts.rs had grown to 1790 lines holding every FTS concern at once. Split it into
a directory: one module per node type (leaf, compound, scorer, match_filter)
plus prefetch, builder, rules, and planner. Largest module is now 397 lines.

The five entry points the rest of the planner uses are unchanged; only where
they live moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nodes.rs, rules.rs, and planner.rs each held every index type's contribution
at once, so adding an index type meant editing three shared files and reading
past two other index types to do it.

Reorganize so the framework is split by stage and each index type keeps its
own node, rules, and lowering together: vector/ and fts/ now reach the
framework through the same five entry points, and take/ holds late
materialization. Cross-index concerns that no single index can decide stay
central: rule ordering in mod.rs, and the coverage split — which serves every
search node type through the SplittableSearch trait — in coverage.rs.

Largest module drops from 1790 to 501 lines. No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests.rs was 1505 lines. Split it so the equivalence oracle and dataset
fixtures live in one harness module and the tests themselves sit next to the
area they cover: scan, vector, fts, and whole-planner properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The logical path rejected v1 datasets outright. It only needed to: v1 and
v2 differ at exactly one node, the scan leaf, and only when it reads ranges
— `Fragment::take_all_tasks` is implemented for v1 while `read_ranges_tasks`
is not, so takes, index lookups and every search node already work
unchanged.

`source/v1.rs` routes that one node through the frozen legacy builder via
the existing `versions::filtered_read` seam, rather than reimplementing it.
The legacy builder does not honor the `TableProvider` contract, so the leaf
re-establishes it on the way out: a filter where the predicate was not
applied (the plain fragment scan branch, which unlike the statistics
pushdown and scalar-index branches leaves it to the caller), and a
projection where the schema does not match. With both in place v1 reports
exact filter pushdown like v2, and keeps the statistics pushdown that is
its only page pruning.

The legacy builder reads its options straight off `&Scanner`, so the leaf
captures an `Arc<Scanner>` — set only on v1 — instead of changing that
frozen signature.

Equivalence tests run the plain-scan shapes against both storage versions,
plus one case per legacy branch.
`include_deleted_rows` is a leaf option on both storage versions: the v2
leaf forwards it to `FilteredReadOptions::with_deleted_rows`, the v1 leaf
to the legacy builder's `make_deletions_null`. It survives a coverage
split, and should — the branches read disjoint fragments, so between them
they still emit every deleted row once. Combined with a search it is
rejected with the same error the imperative path gives, since a deleted row
has no row id for a search to return.

`strict_batch_size` is applied to the finished plan rather than modelled in
it: the node delegates its plan properties to its input and only reshapes
batches, so no rule has anything to say about it.
`ScanPlanningContext` now narrows a searched column's index to the segments
`with_index_segments` named, validating the request the way the imperative
path does: the segments must exist, cover the queried column, and belong to
one logical index.

That surfaced a divergence in metric resolution. A search that names no
metric was being resolved to the element type's default and then compared
against the index, so an index built with a non-default metric was silently
downgraded to brute force. The imperative path adopts the index's metric in
that case; the search node now records whether the metric was asked for, and
only falls back when it was and disagrees. With explicit segments a
disagreement is an error rather than a fallback, since the caller named
segments that cannot answer their question.

Also fixes an indexed search resolving to an empty segment list — when the
scan's fragments reach none of the index's segments, there is no index to
search and the query is brute force.
The builder restates a search's distance ordering above the take, because a
logical `Sort` is the only way to say "this is the result's order". Whether
the take actually preserved that ordering is a physical fact, so only a
physical rule can drop the restatement. Adding `EnforceSorting` to the
physical rule set does, now that take-mode `FilteredReadExec` advertises the
ordering it carries through.

A flat KNN plan is one sort — the top-k — instead of two.
Stages 2-4 read the `Scanner` for four things: the dataset, the fragment
restriction, `fast_search`, and the requested index segments. All four are
already on the scan leaf (or, for the requested metric, on the search node), so
`ScanPlanningContext::collect` now takes only the plan. Any plan whose leaf is a
`LanceScanSource` can be lowered, not just one the scanner's builder produced.

That makes a DataFrame API possible: `SessionContext::read_lance_dataset` reads
a dataset through the logical path's own leaf, `DataFrame::nearest` and
`DataFrame::full_text_search` stack searches on it, and `DataFrame::lance_plan`
lowers the result. A vector search whose input is already a search result scores
exactly rather than consulting an index, matching what the scanner does for a
vector search over a full-text filter.

Not gated by `LANCE_LOGICAL_SCAN_PLANNER`: there is no imperative equivalent.
The builder emits a logical `Aggregate` and lets projection pushdown find the
columns it reads, replacing the imperative path's hand-built `AggregateExec` and
its by-hand `agg_projection` take.

Two things this needed. Aggregate and group expressions are aliased to their
unqualified names, so `sum(i)` does not become `sum(lance.i)` in the output
schema. And the scan leaf now honors an empty projection — `COUNT(*)` asks for no
columns, and the row address the reader needs as a stand-in is projected away
rather than leaking into the aggregate's input.
A multivector column lowers to `Scanner::multivec_ann` in the imperative path.
Nothing in the vector lowering here inspects the column type, so a multivector
search would have built a single-vector fanout over it and returned wrong rows.
Report it as unsupported until the node exists.
An indexed batch expands to a union of one search per query vector; a
brute-force batch stays one node, since KNNVectorDistanceExec already
scores every query in a single pass over the rows.
Multivector lowers to the per-vector fanout plus XTR scoring. A `_rowid`
or `_rowaddr` predicate becomes a row restriction on the scan instead of
a filter. `_rowoffset` gets a node whose fragment offsets are loaded in
stage 2, since building it is the one physical constructor that does IO.

Also fixes AddRowOffsetExec's statistics, which reported the input's
column count while its schema had one more, panicking anything that read
them by index.
The logical path read every projected column in one pass, so an explicit
`materialization_style` was rejected and the default was only approximated.
The leaf now splits the read the way `Scanner::filtered_read_source` does:
with a refine filter it reads the cheap columns alongside the filter and takes
the rest afterwards, then restates the requested projection so the leaf still
returns exactly what it was asked for.

`blob_handling` comes with it — it types the blob columns the leaf reads, and
the width heuristic asks it whether a blob comes back as a description.

Legacy (v1) storage opts out: its statistics-pushdown branch takes its
projection off the `Scanner` and ignores the one it is handed, so narrowing
here would add a take without narrowing the read.
A wide column that the refine filter also reads was counted as deferrable
before the filter's own columns were added back, so the eager read ended up
covering everything the take was supposed to fetch and the take failed with
"the input plan already contains every projected field". Decide after the
union instead, which is also the order `Scanner::calc_eager_projection` uses.
`SimplifyProjection` removed a projection that restated its input exactly,
but left adjacent projections alone, so a plan that reorders columns above a
node that already projects them kept both. A column-only projection over
another projection is just a projection over the same input, so fold it —
and re-examine the result, which is often the no-op the old rule handled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The result vector was sized by subtracting the block list's length from the
row count of the fragments being read. The block list is not restricted to
those fragments — a plan may materialize an index over a subset of them —
so the subtraction could underflow. Saturate instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The logical read path was behind `LANCE_LOGICAL_SCAN_PLANNER=1`, next to the
imperative `Scanner::create_plan`. Make it the only path: the flag is gone,
and with it the hand-written physical builder — the `(nearest,
full_text_query)` dispatch, the vector and FTS source builders, the take and
prefilter plumbing, and the limit pushdown the logical `Limit` expresses.
The legacy (v1) exec nodes stay; the logical path calls them for legacy
storage.

Full-text results are now ordered by `_score` descending then `_rowid`
ascending, stated in the plan rather than inherited from whichever operator
sorted last. Without the tie-break a query with a limit and the same query
without one disagreed on which of two equally relevant rows came first.

The planner's tests compared the two paths against each other, which says
nothing once there is one path. They now state what a query's answer is:
each fixture derives its data from the row's `i`, so the expected rows are
computed in Rust and checked against the scan, column values included.
Approximate search asserts a recall floor against a brute-force oracle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the enhancement New feature or request label Aug 16, 2026
@wjones127

Copy link
Copy Markdown
Contributor Author

Superseded by #8572, the top of stack #8573. That version is deletion only — the FTS _rowid tie-break this PR carried now lands with the rest of the FTS work in #8570.

@wjones127 wjones127 closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant