Skip to content

[opt](lance) push down LIMIT into Lance fragment scanners - #66608

Merged
yiguolei merged 1 commit into
apache:branch-4.1from
Jay-ju:jay-ju/lance-scan-limit-pushdown
Aug 14, 2026
Merged

[opt](lance) push down LIMIT into Lance fragment scanners#66608
yiguolei merged 1 commit into
apache:branch-4.1from
Jay-ju:jay-ju/lance-scan-limit-pushdown

Conversation

@Jay-ju

@Jay-ju Jay-ju commented Aug 10, 2026

Copy link
Copy Markdown

What problem does this PR solve?

Issue Number: N/A

Related PR: #65730, #66581

Problem Summary:

Ordinary Lance scans currently read every row of a fragment even when the
query only needs the first N rows (e.g. SELECT ... LIMIT 10). Lance applies
its own LIMIT after the scanner's filter, so the query LIMIT can be forwarded
to each fragment scanner and let it stop early, cutting IO and decode cost.

How it is fixed

  • thrift: add an optional TLanceFileDesc.limit.
  • FE (LanceScanNode): push the query limit into each fragment split via
    canPushDownLimit(), and surface lanceLimit in the explain output.
  • BE (lance_reader): forward it to the scanner through
    lance_scanner_set_limit for ordinary scans; vector search keeps its own
    top_k limit.

Correctness

The limit is pushed only when all predicates are already pushed into Lance
(no residual Doris conjunct). Otherwise Doris still re-filters the returned rows,
and truncating a fragment early could drop valid results.

OFFSET needs no special handling: Nereids' SplitLimit rewrites
Limit(limit, offset) into a global Limit(limit, offset) over a local
Limit(limit + offset, 0), and that local bound is what reaches the scan node.
So getLimit() already includes the offset; each fragment fetches up to
limit + offset rows and the upper global LIMIT still applies the offset and
the final bound. Per-fragment truncation is therefore always safe.

Behavior change

Query results are unchanged. Only the number of rows scanned per fragment is
reduced for LIMIT queries; the explain output shows an extra lanceLimit=N
line when the limit is pushed.

Release note

Push down LIMIT into Lance fragment scanners to reduce the rows scanned for
LIMIT / LIMIT ... OFFSET queries over Lance tables.

Check List (For Author)

  • Test
    • Unit Test (LanceThriftContractTest covers the limit round-trip and the no-limit case)
    • Manual test — SELECT * FROM <lance_tbl> LIMIT 10 returns 10 rows and EXPLAIN shows lanceLimit=10; a query with a non-pushable predicate keeps the limit out of the scan
  • Behavior changed:
    • No.
  • Does this need documentation?
    • No.

@Jay-ju
Jay-ju requested a review from yiguolei as a code owner August 10, 2026 07:51
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

Ordinary Lance scans read every row of a fragment even when the query
only needs the first N. Lance applies its LIMIT after the scanner's own
filter, so we can forward the query LIMIT to each fragment scanner and
let it stop early.

To stay correct we only push the LIMIT when all predicates are already
pushed into Lance (no residual Doris conjunct). Otherwise Doris still
filters the returned rows and an early truncation could drop valid
results. The upper LIMIT operator keeps enforcing the global bound
across fragments, so per-fragment truncation is always safe.

OFFSET needs no extra work: Nereids' SplitLimit rewrites
Limit(limit, offset) into a global Limit(limit, offset) over a local
Limit(limit + offset, 0), and that local bound is what reaches the scan
node. So getLimit() already includes the offset and each fragment simply
fetches up to limit + offset rows.

- thrift: add optional TLanceFileDesc.limit
- FE: set it in LanceScanNode.setScanParams via canPushDownLimit()
  (hasLimit() and no residual conjunct); also surface it in explain
- BE: forward it via lance_scanner_set_limit for ordinary scans
  (vector search keeps its own top_k limit)
@Jay-ju
Jay-ju force-pushed the jay-ju/lance-scan-limit-pushdown branch from 5ba2501 to 7fb0a61 Compare August 10, 2026 08:56
@Jay-ju

Jay-ju commented Aug 10, 2026

Copy link
Copy Markdown
Author

Thank you for your contribution to Apache Doris. Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

Thanks for the reminder! I've just updated the PR description with the
problem background, the fix, the correctness reasoning (including how
OFFSET is handled) and the test plan. Please let me know if anything is
still unclear — happy to add more detail. 🙏

@Jay-ju

Jay-ju commented Aug 10, 2026

Copy link
Copy Markdown
Author

@hello-stephen Please take a look when you have time, thanks.

@yiguolei

Copy link
Copy Markdown
Contributor

设计和正确性判断总体是合理的,尤其是:
residual predicate 时禁止下推;
保留上层 global LIMIT;
OFFSET 使用 limit + offset;
vector search 与普通 scan 分开处理。
但测试覆盖偏弱。当前新增测试只验证了 Thrift:
设置 limit=100 后序列化/反序列化正常;
未设置时字段仍然是 unset。
LanceThriftContractTest.java

它没有真正覆盖核心逻辑。我会建议至少再补:
可下推 predicate:EXPLAIN 出现 lanceLimit。
residual predicate:不能出现 lanceLimit。
LIMIT 10 OFFSET 100:下推值是 110。
多 fragment 结果正确。
vector search 不走普通 limit。
BE 验证确实调用了 lance_scanner_set_limit。
另外有个很小的不一致:FE 对 LIMIT 0 会显示 lanceLimit=0,但 BE 因为判断 limit > 0 不会设置 Lance scanner limit。通常上层 LIMIT 0 会让扫描根本不执行,所以不是结果正确性问题,但 EXPLAIN 和实际下推行为不完全一致。

@Jay-ju Hello, please add some more tests.

@yiguolei

Copy link
Copy Markdown
Contributor

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

Cloud UT Coverage Report

Increment line coverage 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.56% (1911/2464)
Line Coverage 64.40% (34216/53133)
Region Coverage 64.44% (17294/26838)
Branch Coverage 54.00% (9267/17160)

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 0.00% (0/732) 🎉
Increment coverage report
Complete coverage report

@yiguolei
yiguolei merged commit 4f900d0 into apache:branch-4.1 Aug 14, 2026
28 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants