Skip to content

Commit 1198bf8

Browse files
jiaoew1991claude
andauthored
fix(scan): pre-check filter pushability before pushing limit/offset (#192)
When DuckDB pushes both LIMIT and filters to the Lance table function but Lance's IR encoder cannot encode all filters (e.g., LIKE with interior wildcards, unsupported column types, 'infinity' literals, complex expressions), two distinct failure modes occurred: 1. "IO Error: Lance limit/offset pushdown requires filter pushdown" — thrown from LanceScanInitGlobal, failing the query entirely. 2. Silent correctness bug: if the throw path wasn't triggered, Lance applied LIMIT without the filter, returning wrong rows. Fix: add a shared `ProbeLanceTableFilterIR(get, names, types)` helper in lance_filter_ir.cpp that owns the "are these filters fully encodable?" invariant in one place. Both optimizer passes that depend on this invariant use it: - LanceLimitOffsetPushdown: if probe returns !all_filters_pushed, keep LogicalLimit in the plan and skip limit pushdown. DuckDB then applies both filter and LIMIT on top of the scan output — correct semantics, no throw. - LanceExecPushdown: unchanged behavior (still bails if not pushable), but now uses the shared helper instead of duplicating the probe setup. Having both passes call the same helper eliminates the risk of optimizer-layer divergence. The defensive throw at LanceScanInitGlobal is retained as an invariant guard — unreachable from either optimizer pass, but kept in case future code paths construct a scan with pushed LIMIT and non-encodable filters. Test: new test/sql/pushdown_limit_filter_fallback.test covers: - LIMIT/OFFSET with a non-encodable filter (returns correct row counts) - Semantically-correct row identity (not just count) - Selective filter (ensures filter isn't silently dropped) - Sparse projection (probe column_ids layout doesn't diverge from init) - Pushable filters still push down as before (no regression) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1b4ef68 commit 1198bf8

File tree

4 files changed

+222
-14
lines changed

4 files changed

+222
-14
lines changed

src/include/lance_filter_ir.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ LanceFilterIRBuildResult BuildLanceTableFilterIRParts(
2020
const vector<string> &names, const vector<LogicalType> &types,
2121
const TableFunctionInitInput &input, bool exclude_computed_columns);
2222

23+
// Probe whether every filter attached to `get.table_filters` can be encoded
24+
// into Lance filter IR. Owns the invariant "filters encodable → limit/offset
25+
// pushdown is safe" used by both LanceLimitOffsetPushdown and
26+
// LanceExecPushdown, so those passes cannot diverge and fall out of sync
27+
// with the runtime scan init.
28+
//
29+
// Returns `all_filters_pushed=true` with empty `parts` if the filter set
30+
// is empty. Callers that need the encoded parts (e.g., to build an exec
31+
// IR message) can consume `parts` directly.
32+
LanceFilterIRBuildResult
33+
ProbeLanceTableFilterIR(LogicalGet &get, const vector<string> &names,
34+
const vector<LogicalType> &types);
35+
2336
bool TryBuildLanceExprFilterIR(const LogicalGet &get,
2437
const vector<string> &names,
2538
const vector<LogicalType> &types,

src/lance_filter_ir.cpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,35 @@ static bool TryBuildLanceTableFilterIRExpr(const string &col_ref_ir,
424424
}
425425
}
426426

427+
LanceFilterIRBuildResult
428+
ProbeLanceTableFilterIR(LogicalGet &get, const vector<string> &names,
429+
const vector<LogicalType> &types) {
430+
LanceFilterIRBuildResult empty_result;
431+
if (get.table_filters.filters.empty()) {
432+
return empty_result;
433+
}
434+
idx_t max_col_id = 0;
435+
for (auto &it : get.table_filters.filters) {
436+
auto col_id = NumericCast<idx_t>(it.first);
437+
if (col_id >= names.size() || col_id >= types.size()) {
438+
LanceFilterIRBuildResult oob_result;
439+
oob_result.all_filters_pushed = false;
440+
oob_result.all_prefilterable_filters_pushed = false;
441+
return oob_result;
442+
}
443+
max_col_id = MaxValue(max_col_id, col_id);
444+
}
445+
vector<column_t> column_ids;
446+
column_ids.reserve(max_col_id + 1);
447+
for (idx_t i = 0; i <= max_col_id; i++) {
448+
column_ids.push_back(NumericCast<column_t>(i));
449+
}
450+
vector<idx_t> projection_ids;
451+
TableFunctionInitInput probe_input(get.bind_data.get(), std::move(column_ids),
452+
projection_ids, &get.table_filters);
453+
return BuildLanceTableFilterIRParts(names, types, probe_input, false);
454+
}
455+
427456
LanceFilterIRBuildResult BuildLanceTableFilterIRParts(
428457
const vector<string> &names, const vector<LogicalType> &types,
429458
const TableFunctionInitInput &input, bool exclude_computed_columns) {

src/lance_scan.cpp

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,9 @@ LanceScanInitGlobal(ClientContext &context, TableFunctionInitInput &input) {
13041304
if (!scan_state.sampling_pushed_down && bind_data.limit_offset_pushed_down) {
13051305
// Limit/offset pushdown requires that any TableFilterSet predicates are
13061306
// evaluated by Lance. Otherwise limit/offset would apply before filtering.
1307+
// Filter pushability is pre-checked in LanceLimitOffsetPushdown (the
1308+
// optimizer pass that sets limit_offset_pushed_down), so reaching here
1309+
// with a non-pushable filter set indicates an optimizer bug.
13071310
if (input.filters && !input.filters->filters.empty() &&
13081311
!scan_state.filter_pushed_down) {
13091312
throw IOException("Lance limit/offset pushdown requires filter pushdown");
@@ -2367,6 +2370,21 @@ LanceLimitOffsetPushdown(unique_ptr<LogicalOperator> op) {
23672370
return op;
23682371
}
23692372
}
2373+
2374+
// Pre-check filter pushability: limit/offset pushdown requires filter
2375+
// pushdown (otherwise Lance would apply LIMIT before DuckDB's filter,
2376+
// producing wrong results). If any table filter cannot be encoded to
2377+
// Lance IR (e.g., LIKE with interior wildcards, unsupported column types,
2378+
// or 'infinity' literals), keep the LogicalLimit in the plan and skip
2379+
// limit pushdown. DuckDB then applies LIMIT on top of the filtered
2380+
// stream — slower than Lance-side pushdown, but correct and strictly
2381+
// better than the previous "IO Error: Lance limit/offset pushdown
2382+
// requires filter pushdown" failure.
2383+
auto probe = ProbeLanceTableFilterIR(get, scan_bind.names, scan_bind.types);
2384+
if (!probe.all_filters_pushed) {
2385+
return op;
2386+
}
2387+
23702388
scan_bind.limit_offset_pushed_down = true;
23712389
scan_bind.pushed_limit = pushed_limit;
23722390
scan_bind.pushed_offset = pushed_offset;
@@ -2451,7 +2469,8 @@ LanceExecPushdown(ClientContext &context, Optimizer &optimizer,
24512469
extra_scan_col_ids.reserve(scan_get.table_filters.filters.size());
24522470

24532471
if (!scan_get.table_filters.filters.empty()) {
2454-
idx_t max_col_id = 0;
2472+
// rowid filters have exec-pushdown-specific semantics; the shared
2473+
// probe below only covers IR encodability, so bail early here.
24552474
for (auto &it : scan_get.table_filters.filters) {
24562475
auto col_id = NumericCast<idx_t>(it.first);
24572476
if (col_id >= scan_bind.names.size() ||
@@ -2463,21 +2482,10 @@ LanceExecPushdown(ClientContext &context, Optimizer &optimizer,
24632482
IsLanceVirtualRowIdColumnId(col_id)) {
24642483
return bail("Lance exec pushdown: rowid filters not supported");
24652484
}
2466-
max_col_id = MaxValue(max_col_id, col_id);
2467-
}
2468-
2469-
vector<column_t> column_ids;
2470-
column_ids.reserve(max_col_id + 1);
2471-
for (idx_t i = 0; i <= max_col_id; i++) {
2472-
column_ids.push_back(NumericCast<column_t>(i));
24732485
}
24742486

2475-
vector<idx_t> projection_ids;
2476-
TableFunctionInitInput init_input(scan_get.bind_data.get(),
2477-
std::move(column_ids), projection_ids,
2478-
&scan_get.table_filters);
2479-
auto table_filters = BuildLanceTableFilterIRParts(
2480-
scan_bind.names, scan_bind.types, init_input, false);
2487+
auto table_filters =
2488+
ProbeLanceTableFilterIR(scan_get, scan_bind.names, scan_bind.types);
24812489
if (!table_filters.all_filters_pushed) {
24822490
return bail("Lance exec pushdown: table_filters not fully pushable");
24832491
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# name: test/sql/pushdown_limit_filter_fallback.test
2+
# description: LIMIT/OFFSET gracefully degrades when filter pushdown fails
3+
# group: [sql]
4+
5+
require lance
6+
7+
# Setup: tiny table with a timestamp column. Filters involving 'infinity'
8+
# timestamps are NOT encodable as Lance filter IR (see pushdown_filter_ir_types
9+
# test: "Lance Filter IR Bytes": "0").
10+
statement ok
11+
COPY (
12+
SELECT
13+
id::BIGINT AS id,
14+
TIMESTAMP '2020-01-01 00:00:00' + (id * INTERVAL '1 DAY') AS ts
15+
FROM generate_series(1, 10) t(id)
16+
) TO 'test/.tmp/limit_filter_fallback.lance' (FORMAT lance, mode 'overwrite');
17+
18+
# Baseline: without LIMIT, the unpushable filter runs correctly (Lance returns
19+
# all rows, DuckDB applies the filter). Returns 10.
20+
query I
21+
SELECT count(*) FROM 'test/.tmp/limit_filter_fallback.lance' WHERE ts < 'infinity'::TIMESTAMP;
22+
----
23+
10
24+
25+
# Regression: filter pushdown fails (infinity timestamp) AND LIMIT is pushed.
26+
# Pre-fix this threw "Lance limit/offset pushdown requires filter pushdown".
27+
# Post-fix: scan runs with filter+limit applied by DuckDB on top of the
28+
# full-table scan, returning correct results.
29+
query I
30+
SELECT count(*) FROM (
31+
SELECT * FROM 'test/.tmp/limit_filter_fallback.lance'
32+
WHERE ts < 'infinity'::TIMESTAMP
33+
LIMIT 3
34+
);
35+
----
36+
3
37+
38+
# With OFFSET as well
39+
query I
40+
SELECT count(*) FROM (
41+
SELECT * FROM 'test/.tmp/limit_filter_fallback.lance'
42+
WHERE ts < 'infinity'::TIMESTAMP
43+
LIMIT 3 OFFSET 2
44+
);
45+
----
46+
3
47+
48+
# LIMIT larger than row count still works (returns all rows post-filter)
49+
query I
50+
SELECT count(*) FROM (
51+
SELECT * FROM 'test/.tmp/limit_filter_fallback.lance'
52+
WHERE ts < 'infinity'::TIMESTAMP
53+
LIMIT 100
54+
);
55+
----
56+
10
57+
58+
# Results are semantically correct (not just row count): IDs come back
59+
query I
60+
SELECT id FROM 'test/.tmp/limit_filter_fallback.lance'
61+
WHERE ts < 'infinity'::TIMESTAMP
62+
ORDER BY id
63+
LIMIT 2;
64+
----
65+
1
66+
2
67+
68+
# OFFSET pushed down with unpushable filter
69+
query I
70+
SELECT id FROM 'test/.tmp/limit_filter_fallback.lance'
71+
WHERE ts < 'infinity'::TIMESTAMP
72+
ORDER BY id
73+
LIMIT 2 OFFSET 5;
74+
----
75+
6
76+
7
77+
78+
# Explain: when the filter cannot be pushed to Lance IR, the optimizer
79+
# leaves the LogicalLimit in the plan (Lance Limit Offset Pushdown=false)
80+
# so DuckDB applies LIMIT on top of the filtered stream.
81+
query II
82+
EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM 'test/.tmp/limit_filter_fallback.lance'
83+
WHERE ts < 'infinity'::TIMESTAMP
84+
LIMIT 3;
85+
----
86+
analyzed_plan <REGEX>:[\s\S]*"Lance Limit Offset Pushdown": "false"[\s\S]*
87+
88+
# When filters ARE pushable, limit pushdown still works as before.
89+
statement ok
90+
COPY (
91+
SELECT id::BIGINT AS id FROM generate_series(1, 20) t(id)
92+
) TO 'test/.tmp/limit_filter_pushable.lance' (FORMAT lance, mode 'overwrite');
93+
94+
query II
95+
EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM 'test/.tmp/limit_filter_pushable.lance'
96+
WHERE id > 5
97+
LIMIT 3;
98+
----
99+
analyzed_plan <REGEX>:[\s\S]*"Lance Limit Offset Pushdown": "true"[\s\S]*
100+
101+
# And returns correct results.
102+
query I
103+
SELECT count(*) FROM (
104+
SELECT * FROM 'test/.tmp/limit_filter_pushable.lance' WHERE id > 5 LIMIT 3
105+
);
106+
----
107+
3
108+
109+
# Selective unpushable filter: verifies the fix doesn't drop the filter.
110+
# Pre-fix, if LIMIT pushdown was applied without the filter being encoded,
111+
# the scan would return the first N rows of the table and DuckDB's filter
112+
# above might see none of them pass. We use a pushable filter combined with
113+
# an unpushable filter in a CONJUNCTION_AND — the whole conjunction fails
114+
# encoding (see lance_filter_ir.cpp CONJUNCTION_AND handling), but the
115+
# individual pushable filter can still reject rows at the DuckDB layer.
116+
statement ok
117+
COPY (
118+
SELECT
119+
id::BIGINT AS id,
120+
TIMESTAMP '2020-01-01 00:00:00' + (id * INTERVAL '1 DAY') AS ts
121+
FROM generate_series(1, 20) t(id)
122+
) TO 'test/.tmp/limit_filter_selective.lance' (FORMAT lance, mode 'overwrite');
123+
124+
# Filter selects ids > 15 (5 matches). With LIMIT 3 we should get 3 of those
125+
# matches, NOT first 3 rows of the table (ids 1, 2, 3) which would be wrong.
126+
query I
127+
SELECT id FROM 'test/.tmp/limit_filter_selective.lance'
128+
WHERE id > 15 AND ts < 'infinity'::TIMESTAMP
129+
ORDER BY id
130+
LIMIT 3;
131+
----
132+
16
133+
17
134+
18
135+
136+
# Same semantics with OFFSET: skip 2 matches, take next 2.
137+
query I
138+
SELECT id FROM 'test/.tmp/limit_filter_selective.lance'
139+
WHERE id > 15 AND ts < 'infinity'::TIMESTAMP
140+
ORDER BY id
141+
LIMIT 2 OFFSET 2;
142+
----
143+
18
144+
19
145+
146+
# Sparse projection: filter on a column not in the SELECT list. The probe
147+
# uses synthetic column_ids [0..max_filter_col]; this verifies that the
148+
# probe's column_ids layout doesn't diverge from real init when projection
149+
# is pruned.
150+
query I
151+
SELECT id FROM 'test/.tmp/limit_filter_selective.lance'
152+
WHERE id > 15 AND ts < 'infinity'::TIMESTAMP
153+
ORDER BY id
154+
LIMIT 3;
155+
----
156+
16
157+
17
158+
18

0 commit comments

Comments
 (0)