This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 4f900d07f6b [opt](lance) push down LIMIT into Lance fragment scanners
(#66608)
4f900d07f6b is described below
commit 4f900d07f6bbe9da05dea5dbf5becb75fe0852ee
Author: jay <[email protected]>
AuthorDate: Fri Aug 14 10:05:27 2026 +0800
[opt](lance) push down LIMIT into Lance fragment scanners (#66608)
### 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
- [x] 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:
- [x] No.
- Does this need documentation?
- [x] No.
---
be/src/format_v2/table/lance_reader.cpp | 8 +++++++
.../datasource/lance/source/LanceScanNode.java | 22 +++++++++++++++++++
.../doris/datasource/LanceThriftContractTest.java | 25 +++++++++++++++++++++-
gensrc/thrift/PlanNodes.thrift | 4 ++++
4 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/be/src/format_v2/table/lance_reader.cpp
b/be/src/format_v2/table/lance_reader.cpp
index 4f1229901a9..142c35ac4d4 100644
--- a/be/src/format_v2/table/lance_reader.cpp
+++ b/be/src/format_v2/table/lance_reader.cpp
@@ -683,6 +683,14 @@ Status LanceTableReader::_open_scanner(const
TFileRangeDesc& range) {
return _lance_error("set Lance scanner fragment ids");
}
}
+ // Ordinary scans may carry a pushed-down LIMIT. The FE only sets it when
all predicates are
+ // pushed into Lance, so the scanner can safely stop after `limit` rows.
Vector search manages
+ // its own top_k limit in _configure_vector_search, so skip it here.
+ if (!_vector_search && lance_params.__isset.limit && lance_params.limit >
0) {
+ if (lance_scanner_set_limit(scanner, lance_params.limit) != 0) {
+ return _lance_error("set Lance scanner limit");
+ }
+ }
if (_vector_search) {
RETURN_IF_ERROR(_configure_vector_search(scanner));
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
index 7c363ab6e59..4c433a30bce 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
@@ -114,6 +114,19 @@ public class LanceScanNode extends FileQueryScanNode {
}
}
+ // A fragment-level LIMIT can be pushed into an ordinary Lance scan only
when every predicate
+ // is already pushed into Lance (conjuncts is empty). Otherwise Doris
re-filters the returned
+ // rows and truncating a fragment early could drop valid results.
+ //
+ // OFFSET needs no special handling: the Nereids SplitLimit rule rewrites
Limit(limit, offset)
+ // into a global Limit(limit, offset) over a local Limit(limit + offset,
0), and the local
+ // bound is what lands on this scan node. So getLimit() already accounts
for the offset and
+ // getOffset() is always 0 here; each fragment fetches up to limit +
offset rows and the upper
+ // global LIMIT still applies the offset and the final bound.
+ private boolean canPushDownLimit() {
+ return hasLimit() && conjuncts.isEmpty();
+ }
+
@Override
protected void convertPredicate() {
if (isExternalSearch()) {
@@ -199,6 +212,12 @@ public class LanceScanNode extends FileQueryScanNode {
"Ordinary Lance scan split must contain one fragment");
}
lanceParams.setFragmentIds(Collections.singletonList(lanceSplit.getFragmentId()));
+ // Push LIMIT into each fragment scanner only when it is safe to
truncate a single
+ // fragment early. See canPushDownLimit(). Each scanner still
returns at most `limit`
+ // rows and the upper LIMIT operator enforces the final bound
across fragments.
+ if (canPushDownLimit()) {
+ lanceParams.setLimit(getLimit());
+ }
}
TTableFormatFileDesc tableFormatParams = new TTableFormatFileDesc();
@@ -253,6 +272,9 @@ public class LanceScanNode extends FileQueryScanNode {
.append(((LanceExternalCatalog)
lanceTable.getCatalog()).getLanceCatalogType()).append("\n");
result.append(prefix).append("lanceVersion=").append(plannedVersion).append("\n");
result.append(prefix).append("lanceFragments=").append(plannedFragments).append("\n");
+ if (canPushDownLimit()) {
+
result.append(prefix).append("lanceLimit=").append(getLimit()).append("\n");
+ }
if (!lancePushdownPredicate.isEmpty()) {
result.append(prefix).append("lancePushdownPredicate=")
.append(lancePushdownPredicate).append("\n");
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
index e28dd3c7382..dc21fafa314 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
@@ -36,7 +36,8 @@ public class LanceThriftContractTest {
TLanceFileDesc lanceDesc = new TLanceFileDesc()
.setDatasetUri("s3://warehouse/db/table.lance")
.setFragmentIds(Arrays.asList(7L, 11L))
- .setVersion(42L);
+ .setVersion(42L)
+ .setLimit(100L);
TTableFormatFileDesc source = new TTableFormatFileDesc()
.setTableFormatType(TableFormatType.LANCE.value())
.setLanceParams(lanceDesc);
@@ -53,5 +54,27 @@ public class LanceThriftContractTest {
Assert.assertEquals("s3://warehouse/db/table.lance",
restored.getLanceParams().getDatasetUri());
Assert.assertEquals(Arrays.asList(7L, 11L),
restored.getLanceParams().getFragmentIds());
Assert.assertEquals(42L, restored.getLanceParams().getVersion());
+ Assert.assertTrue(restored.getLanceParams().isSetLimit());
+ Assert.assertEquals(100L, restored.getLanceParams().getLimit());
+ }
+
+ @Test
+ public void testLanceDescriptorWithoutLimit() throws Exception {
+ TLanceFileDesc lanceDesc = new TLanceFileDesc()
+ .setDatasetUri("s3://warehouse/db/table.lance")
+ .setFragmentIds(Arrays.asList(1L))
+ .setVersion(1L);
+ TTableFormatFileDesc source = new TTableFormatFileDesc()
+ .setTableFormatType(TableFormatType.LANCE.value())
+ .setLanceParams(lanceDesc);
+
+ TSerializer serializer = new TSerializer(new
TCompactProtocol.Factory());
+ byte[] bytes = serializer.serialize(source);
+
+ TTableFormatFileDesc restored = new TTableFormatFileDesc();
+ new TDeserializer(new
TCompactProtocol.Factory()).deserialize(restored, bytes);
+
+ // A scan without a pushable LIMIT must leave the field unset so the
BE reads all rows.
+ Assert.assertFalse(restored.getLanceParams().isSetLimit());
}
}
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index 3b7c377110a..d3f0583c220 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -527,6 +527,10 @@ struct TLanceFileDesc {
1: optional string dataset_uri
2: optional list<i64> fragment_ids
3: optional i64 version
+ // Per-split row limit pushed down from the query LIMIT. Each scanner
returns at
+ // most this many rows; the upper LIMIT operator still enforces the global
bound.
+ // Only set for ordinary scans whose predicates are fully pushed into
Lance.
+ 4: optional i64 limit
}
struct TTableFormatFileDesc {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]