feat(pushdown): push array_has_any/array_has_all - #237
Conversation
Cargo.toml requires datafusion 54 but the committed lock still pins 53.1.0, so `cargo --locked` fails on a clean checkout and every build regenerates the lock as an unrelated diff.
DuckDB's Arrow exporter hardcodes the list child field name to `l` (`duckdb/src/common/arrow/arrow_converter.cpp`). Arrow, pyarrow, Lance and DataFusion all use `item`, and Lance persists whatever name it receives. DataFusion's coercion for array-signature functions normalizes every list argument to `DataType::new_list(elem, nullable)`, whose child is always `item`. It therefore wraps a column whose child is called `l` in `CAST(col AS List(..))`, and Lance's scalar-index planner only matches a bare column reference, so a LABEL_LIST index can never answer a query over a list column we wrote. The coercion rewrites both arguments to the same canonical shape, so no change on the literal side avoids it. Normalize the name at every DuckDB -> Lance write boundary: COPY, INSERT, CREATE TABLE, CTAS, namespace create, and both ALTER TABLE paths. Lance rejects an append whose schema disagrees with the destination, so the writer now reads the destination schema in append mode and renames its list children to match whatever that dataset already uses. Datasets written before this change keep `l` and stay appendable; new ones get `item`. The writer also realigns the imported `data_type`, otherwise RecordBatch validation fails on the incoming arrays instead.
Admit LIST columns through the filter IR type gate and add a LIST literal tag (a length-prefixed sequence of the existing scalar literal payloads, recursive), so both the TableFilterSet and the general Expression builders can encode containment predicates. Rust decodes the tag into a ScalarValue::List and resolves the two UDFs from datafusion-functions-nested, promoted here from a transitive dependency to an explicit one. Lance's scalar-index planner recognizes those two function names against a LABEL_LIST-indexed column. DuckDB's `list_has_*` spelling maps onto the same DataFusion names, so the IR always carries the DataFusion spelling and the two vocabularies stay decoupled. The `&&` / `@>` / `<@` operator aliases stay out: DuckDB aliases `<@` to `list_has_all` without swapping its arguments, which contradicts Postgres, and they bind under their own names, so they never reach this path. Empty needles and needles containing NULL fall back to DuckDB rather than changing results. DuckDB ignores NULL elements and short-circuits an empty `list_has_all` to true, where DataFusion compares NULLs by value and combines the row null mask. Only the containment branches emit the LIST tag; the general literal encoder ignores it. Wiring it in would make `tags = ['a','b']` pushable as a DataFusion list equality, where Arrow's nested comparison kernels are patchy and the NULL semantics may not match. It falls back today, which is correct. Admitting LIST to the type gate also pushes down IS NULL and IS NOT NULL on list columns, which matches Arrow semantics. Covers plain scans and lance_vector_search, which already had the pushdown_complex_filter hook.
…arch Both functions registered `filter_pushdown` and `pushdown_expression` but never `pushdown_complex_filter`. Simple comparisons arrived as TableFilters and pushed fine; the optimizer dropped anything DuckDB models as an expression. `prefilter := true` did not error in that case, it just quietly did nothing, so DuckDB always post-checked a containment predicate after the search had already run over the whole dataset. `LancePushdownComplexFilter` is typed against `LanceKnnBindData`, so this extracts its body into `CollectLancePushedFilterIRParts`, gives the two search functions a hook over the `LanceSearchBindData` they already share, and adds the matching `lance_pushed_filter_ir_parts` assembly in `LanceSearchInitGlobal`. This is wider than containment: the same missing hook dropped `starts_with`, `LIKE` and `regexp_matches`, which now prefilter too, matching what `lance_vector_search` and plain scans have always done. It is a behaviour change for anyone who was relying on the old post-filtering by accident.
`lance_vector_search` and `lance_fts` rejected `prefilter := true` on a namespace-backed table unless the caller also passed an explicit `filter := '<string>'` argument. The check ran during bind, before the optimizer had offered `pushdown_complex_filter` anything, so a `WHERE` clause could not satisfy it by construction, and the only way to prefilter was to interpolate values into a filter string. Three things stood between a pushed predicate and the namespace request. Both complex-filter hooks returned early for `namespace_backed`, so they collected nothing. Both `InitGlobal` paths returned before assembling the filter IR. And the two FFI entry points had no way to carry it. The namespace scan path already solved this: `lance_create_namespace_scan_stream_ir` takes the IR, decodes it, and unparses the expression into `QueryTableRequest.filter`. The search entry points now take the same two arguments and share that machinery via `apply_filter_expr`, which also replaces the equivalent inline block in `build_namespace_scan_request`. When a query supplies both a pushed predicate and an explicit `filter` argument, the two combine with AND rather than one replacing the other. The init-time check the non-namespace path already uses replaces the bind-time guard: if `prefilter` is on and a prefilterable column could not be pushed, it throws there instead. `lance_hybrid_search` never carried the guard and keeps working; it now takes the same route as the other two. Two tests in `search_functions.test` asserted the old error as the contract. They now assert that the `WHERE` clause prefilters with no explicit filter.
WHERE predicates now push down from all three search functions for both dataset paths and attached namespace tables, so the namespace bullets no longer match the code: pushed predicates translate to a query_table filter and combine with an explicit `filter` using AND, and prefilter=true no longer requires the explicit parameter. Adds a filter pushdown notes section covering containment predicates, the LABEL_LIST index, legacy `l`-named list children, and the VARCHAR IN limitation.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
Search filtering must preserve one contract across path- and namespace-backed execution: required prefilters must be complete before top-k, while optional pushdown must fall back to DuckDB without failing the query. This revision violates both sides of that contract.
A viable revision would make complex-filter collection report completeness and use one stream-creation policy across dataset and namespace searches: reject unencodable predicates when prefilter=true, and retry without generated IR when prefilter=false while preserving explicit user filters.
| string filter_ir; | ||
| if (!TryBuildLanceExprFilterIR(get, scan_bind.names, scan_bind.types, true, | ||
| *expr, filter_ir)) { | ||
| if (!TryBuildLanceExprFilterIR(get, names, types, true, *expr, filter_ir)) { |
There was a problem hiding this comment.
Failure to encode this predicate is silently ignored. With prefilter=true, top-k is consequently computed before the missing predicate and DuckDB only filters the truncated result, producing incorrect output. Return completeness from this collector and reject required prefiltering whenever any predicate cannot be encoded; silent skipping is valid only for prefilter=false.
Executed reproducer
COPY (
SELECT * FROM (VALUES
(1::BIGINT, [0.0,0.0]::FLOAT[2], NULL::VARCHAR[]),
(2::BIGINT, [1.0,0.0]::FLOAT[2], ['x']::VARCHAR[])
) t(id,vec,tags)
) TO 'test/.tmp/pr237_empty_needle_prefilter.lance'
(FORMAT lance, MODE 'overwrite');
SELECT id
FROM lance_vector_search(
'test/.tmp/pr237_empty_needle_prefilter.lance',
'vec', [0.0,0.0]::FLOAT[2],
k := 1, prefilter := true, use_index := false
)
WHERE array_has_all(tags, []::VARCHAR[]);Expected 2; observed zero rows.
A predicate that failed to encode into filter IR was silently dropped from the pushed prefilter. With prefilter=true, Lance then ran top-k before the missing predicate and DuckDB post-filtered the truncated result, losing rows that a complete prefilter would have returned. The collector now reports completeness. When prefilter=true and any predicate cannot be encoded, the query fails with a clear error naming the predicate. Bound parameters stay exempt: PREPARE plans with the parameter unbound and every EXECUTE re-plans with it folded to a pushable constant. Volatile predicates are rejected; they never fold. Silent skipping remains the documented semantics for prefilter=false.
…d IR Namespace-backed stream creation threw as soon as the backend rejected the generated filter IR, while the dataset-backed path retried without it. A predicate that encodes on the DuckDB side but that Lance cannot parse (for example a regexp option flag) failed the whole query on namespace tables and succeeded on path-backed ones. One stream-creation policy now serves all four sites through a shared helper: with prefilter=false a failed open retries without the pushed IR, clears the pushed-down state, counts the fallback, and DuckDB re-applies the predicate after top-k. The explicit namespace filter survives the retry because it travels in the query config, not in the IR. With prefilter=true the IR is required, so the failure surfaces.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The two previously reported filtering defects are resolved: required filters are now checked for completeness, and optional namespace pushdown retries correctly.
The new rejection path still needs a safe lifecycle boundary. Path-backed searches open a Lance session before the complex-filter callback throws; at process teardown that session closes after Tokio's context is gone, causing a panic and leaving DuckDB hung. Reject before acquiring that session, or make session cleanup safe after runtime teardown.
A query rejected during optimization leaves its bind-time session to be closed by a C++ static destructor at process exit, after the thread's TLS destructors have run. RUNTIME is a static and never drops, so the initialized_runtime() guard passed and block_on entered tokio's context thread-local, which panics after destruction and wedges shutdown. Probe Handle::try_current() first and skip the cache clear when the error reports the thread-local as destroyed. The caches die with the process, so skipping is safe. Verified with the gate reproducer on a debug build: the rejection error now exits promptly instead of hanging.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The revision now preserves the search-filter contract across path- and namespace-backed execution: required predicates are complete or rejected, optional pushdown falls back without losing explicit filters, and rejected path-backed searches tear down cleanly.
The teardown guard is narrowly limited to the Tokio destroyed-thread-local state; normal session close still clears caches through the initialized runtime.
feat(pushdown): push array_has_any/array_has_all into Lance
Investigated, implemented and tested with the help of Claude Code.
Summary
No containment predicate on a
LISTcolumn could prefilter a search.LanceExprIRSupportsLogicalTypegated on the column's
LogicalTypeId, and rejectedLISTbefore it looked at the function, soarray_has_anyandarray_has_allnever left DuckDB whileid > 5,INandLIKEon scalar columnspushed normally. DuckDB applied the containment predicate after the search had already run, so callers had
to oversample
kuntil the candidate pool covered the whole table, or the search silently missed matchingrows outside the top-k.
prefilter := trueneither helped nor errored, it quietly did nothing. Lance canalready answer these predicates from a
LABEL_LISTindex(
LabelListQueryParser::visit_scalar_functioninlance-index); the extension never handed them over.This PR does that, and fixes three further things that sat between sending the predicate and Lance using
the index.
Before and After
nsis any catalog fromATTACH '<dir>' AS ns (TYPE LANCE).✅means the predicate reaches Lance andLance filters,
❌means it never left DuckDB. A scan has noprefilterargument and no top-k, so a missedpushdown costs time. On a search it changes the answer: the search picks its top-k before applying the
predicate, so you get back fewer rows than you asked for.
lance_hybrid_searchlance_vector_searchlance_fts'data/items.lance'ns.main.itemsContainment worked nowhere, under either way of addressing the table, and the index was unreachable by
construction. With a
LABEL_LISTindex on the list column the scan✅is an index lookup rather than afull read; without one the predicate still pushes and Lance answers it by scanning.
LABEL_LISTis theonly scalar index that answers containment.
The same defects blocked other predicates, so those are fixed by construction rather than as extra scope.
Cells with a single
✅already worked and are unchanged.lance_hybrid_searchlance_vector_searchlance_ftsid > 5,=, numericIN'data/items.lance'ns.main.itemsstarts_with/LIKE/regexp'data/items.lance'ns.main.itemsIS NULLon a list column'data/items.lance'ns.main.itemsThe first two predicate groups resolve to a
BTREEindex when one exists on the compared column. No scalarindex answers
IS NULLon a list column, so that row always scans.One case this PR does not fix:
INover aVARCHARcolumn with two or more values throwsrequires filter pushdown for prefilterable columnsfrom all three search functions, before and after.The same predicate pushes on a scan, and pushes from a search under
prefilter := false, so the encoderhandles it; the prefilterable check rejects it.
CONJUNCTION_ORoverVARCHARfails the same way.Out of scope here, but worth an issue.
Design
LanceExprIRSupportsLogicalTyperejectedLogicalTypeId::LIST, so any filter touching a list columnbailed out before anything examined the function name, and the IR had no list literal. This admits
LISTto the type gate, adds a
LISTliteral tag (a length-prefixed sequence of the existing scalar payloads,recursive), and gives containment branches to both the
TableFilterSetandExpressionbuilders. Rustdecodes the tag into a
ScalarValue::Listand resolves the two UDFs fromdatafusion-functions-nested,promoted from a transitive dependency to an explicit one. The IR always carries the DataFusion spelling, so
DuckDB's
list_has_any/list_has_allaliases stay decoupled from it.Search Functions
lance_ftsandlance_hybrid_searchregisteredfilter_pushdownandpushdown_expressionbut neverpushdown_complex_filter, so simple comparisons pushed while the optimizer dropped anything DuckDB modelsas an expression.
LancePushdownComplexFilteris typed againstLanceKnnBindData, so this extracts itsbody into
CollectLancePushedFilterIRPartsand gives both functions a hook over theLanceSearchBindDatathey already share. This is wider than containment:
starts_with,LIKEandregexp_matcheshave neverprefiltered from those two functions either, and now do.
Namespace-Backed Tables
lance_vector_searchandlance_ftsrejectedprefilter := trueon an attached namespace table withoutan explicit
filter := '<string>'. That guard was correct when #217 added it:query_tabletakes a filterstring, DuckDB supplies an IR, and no converter existed. #232 then added
filter_expr_to_sqlfor namespacescans, but nothing pointed the search path at it. The two search entry points now take the same
filter_irarguments aslance_create_namespace_scan_stream_irand share its machinery viaapply_filter_expr; a pushed predicate and an explicitfiltercombine with AND. The init-time check thepath-backed code already used replaces the bind-time guard. The effect is wider than the guard suggests: no pushed
predicate of any kind previously reached a namespace-backed search, and
prefilter := truewithout anexplicit filter threw outright rather than degrading.
List Field Naming
DuckDB's Arrow exporter hardcodes the list child field name to
l, while Arrow, pyarrow, Lance andDataFusion use
item, and Lance persists whatever it receives. DataFusion's array-signature coercionnormalizes every list argument to a child named
item, so anlcolumn gets wrapped in a cast and Lance'smaybe_indexed_columnno longer matches it. The predicate pushed and prefiltered, but Lance answered itwith a scan:
LanceNormalizeArrowListFieldNamesrewrites the name toitemat every write boundary:COPY,INSERT,CREATE TABLE, CTAS, namespace create, and bothALTER TABLEpaths. The plan then readsrefine_filter=--with aScalarIndexQuery … @tags_idx(LabelList).Compatibility
Renaming on write broke appends to older datasets, since Lance rejects a schema that disagrees with the
destination. The writer now reads the destination schema in
Appendmode and matches whatever that datasetalready uses, realigning the imported
data_typealongside it. New datasets getitem, existing ones keepl.Two behaviour changes.
lance_ftsandlance_hybrid_searchnow genuinely prefilter, which changes resultsfor anyone who set
prefilter := trueand was silently getting post-filtering. And datasets written beforethis change keep
land will not use the index; they read, append and prefilter correctly, Lance justanswers with a scan. One metadata-only rename fixes a dataset, with no data rewrite:
ALTER TABLE ns.main.t RENAME COLUMN "tags.l" TO item.Deliberate Exclusions
Empty needles and NULL needle elements fall back to DuckDB, because DuckDB short-circuits an empty
list_has_allto true and ignores NULL elements where DataFusion compares them by value. A non-constantsecond argument falls back too. Also excluded: the singular
array_has/array_contains, and the&&/@>/<@aliases, since DuckDB registers<@without swapping its arguments, unlike Postgres. Only thecontainment branches emit the
LISTtag, keepingtags = ['a','b']off a DataFusion list equality whosenested comparison kernels are patchy. Admitting
LISTto the type gate does push downIS NULLandIS NOT NULLon list columns, which matches Arrow semantics.Two scope notes on the index, found while testing and left alone. The
LABEL_LISTindex is reached forVARCHAR[]andBIGINT[]element types. Narrower integer widths encode through the sameI64literaltag, so DataFusion coerces the column instead of the literal and the planner falls back to a scan; the
predicate still pushes and still prefilters, it is only the index lookup that is lost. An element-type hint
on the
LISTtag would close it. Separately,align_list_field_namesdescends into struct children butnot into nested lists, so a legacy
List<List<T>>column would still fail to append.Test Plan
pushdown_filter_ir_containment.test: both functions and both DuckDB spellings across plain scans,lance_vector_search,lance_ftsandlance_hybrid_search;VARCHAR[]andBIGINT[]elements; boundparameters; a k-semantics probe; index usage asserted through
explain_verbose; every negative casechecked for correct results and for not being pushed.
pushdown_filter_ir_containment_namespace.test: the same ground against an attached catalog. The fixturemakes the tagged rows the worst matches, so at
k := 2a search that filters after top-k returnsnothing, and every assertion proves prefiltering from its row set alone.
search_functions.testasserted the removed bind-time error as the contract, and now assertthat a
WHEREclause prefilters without an explicit filter.silent: a plan that stops pushing still returns the right rows, from a full scan.
cargo clippy --all-targetsclean.Docs
docs/sql.mdsearch filter semantics updated to match: pushedWHEREpredicates and an explicitfiltercombine withAND, and a new "Filter pushdown notes" section covers containment predicates,the
LABEL_LISTindex, and the legacylfield-name remedy.Note on Cargo.lock
The lock diff sits in its own commit and is unrelated.
Cargo.tomlonmainalready requiresdatafusion = "54.0.0"while the committed lock pins53.1.0, so--lockedfails onmaintoday and anybuild regenerates it. This feature's own dependency change is one line of
Cargo.toml.