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 284569abc55 [feature](lance) push COUNT(*) down to Lance dataset
metadata (#66999)
284569abc55 is described below
commit 284569abc5566d8a3ff05b4c5210000a604875c4
Author: jay <[email protected]>
AuthorDate: Mon Sep 7 09:16:27 2026 +0800
[feature](lance) push COUNT(*) down to Lance dataset metadata (#66999)
## Summary
- Answer COUNT(*)/COUNT(1) with no filter from the Lance dataset logical
(post-deletion) row count instead of scanning any fragment.
- FE (LanceScanNode): add canPushDownCountStar(), stricter than the
LIMIT pushdown gate (empty conjuncts AND empty Lance Substrait filter).
Emit a single whole-dataset split carrying the logical row count;
table_level_row_count is always set explicitly (-1 for ordinary/search
scans), matching the Iceberg convention.
- BE (lance_reader): drop the hardcoded _remaining_table_level_count =
-1 and short-circuit prepare_split()/get_block() when
_is_table_level_count_active().
## Tests
- New test_lance_optimize_count asserts EXPLAIN shows the metadata count
with no filter and falls back to a normal scan (matching results) with a
filter or when the switch is off.
- New multi_frag.lance fixture (3 fragments, one deleted row each: 30
physical / 27 logical) plus build/self-check; proves the count reports
the logical total and that a multi-split scan applies each fragment
deletion vector exactly once. Committed as binary, consistent with
existing all_types.lance / iceberg preinstalled data.
---
be/src/format_v2/table/lance_reader.cpp | 14 +-
.../scripts/lance_build_preinstalled_catalog.py | 77 ++++++++++
.../_deletions/0-3-12903222946140852451.arrow | Bin 0 -> 698 bytes
.../_deletions/1-3-3997032015744280171.arrow | Bin 0 -> 698 bytes
.../_deletions/2-3-16205658664475652884.arrow | Bin 0 -> 698 bytes
.../0-9d78d1d6-4352-45af-81d3-11147a3165c5.txn | Bin 0 -> 220 bytes
.../1-4ba9d959-a46f-4ecc-bc97-edc7030c82ef.txn | Bin 0 -> 124 bytes
.../2-1b03ecda-1a9c-411c-b9f0-f91af2575aa7.txn | Bin 0 -> 124 bytes
.../3-4b3d53f1-4352-4d80-a8d9-312412d6e5c6.txn | Bin 0 -> 364 bytes
.../_versions/18446744073709551611.manifest | Bin 0 -> 890 bytes
.../_versions/18446744073709551612.manifest | Bin 0 -> 596 bytes
.../_versions/18446744073709551613.manifest | Bin 0 -> 513 bytes
.../_versions/18446744073709551614.manifest | Bin 0 -> 526 bytes
.../_versions/latest_version_hint.json | 1 +
...100000010110011ff221e4e9d80211c9e467a875c.lance | Bin 0 -> 1191 bytes
...110010111111100e282674630a37f9b31b69c3b3f.lance | Bin 0 -> 1191 bytes
...00001000001100118a7f64f1c805a2e133d8d5c21.lance | Bin 0 -> 1191 bytes
.../datasource/lance/source/LanceScanNode.java | 98 ++++++++++---
.../doris/datasource/lance/source/LanceSplit.java | 17 +++
.../datasource/lance/source/LanceScanNodeTest.java | 46 +++++-
.../lance/test_lance_optimize_count.out | 31 ++++
.../lance/test_lance_optimize_count.groovy | 160 +++++++++++++++++++++
22 files changed, 422 insertions(+), 22 deletions(-)
diff --git a/be/src/format_v2/table/lance_reader.cpp
b/be/src/format_v2/table/lance_reader.cpp
index e8ad0c5229a..860623613fd 100644
--- a/be/src/format_v2/table/lance_reader.cpp
+++ b/be/src/format_v2/table/lance_reader.cpp
@@ -431,12 +431,15 @@ Status LanceTableReader::prepare_split(const
SplitReadOptions& options) {
_eof = false;
RETURN_IF_ERROR(TableReader::prepare_split(options));
- // Lance does not currently provide metadata aggregate pushdown. Do not
let a generic
- // table-level count supplied by a future planner bypass fragment reads.
- _remaining_table_level_count = -1;
if (current_split_pruned()) {
return Status::OK();
}
+ // COUNT(*)/COUNT(1) with no filter is served from Lance metadata. The
base class already set
+ // _remaining_table_level_count from the split's table_level_row_count, so
skip opening any
+ // dataset scanner; get_block() synthesizes the counted rows.
+ if (_is_table_level_count_active()) {
+ return Status::OK();
+ }
if (_global_rowid_output_idx.has_value() &&
!_global_rowid_context.has_value()) {
return Status::InvalidArgument(
"Lance global row id requested without global row id context");
@@ -457,6 +460,11 @@ Status LanceTableReader::get_block(Block* block, bool*
eos) {
*eos = true;
return Status::OK();
}
+ // Metadata COUNT(*) split: no scanner is opened. Emit synthetic rows for
the upper COUNT
+ // operator directly from the row count the base class parsed out of the
split.
+ if (_is_table_level_count_active()) {
+ return _read_table_level_count(block, eos);
+ }
if (_scanner == nullptr) {
return Status::InternalError("Lance scanner is not initialized for the
current split");
}
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
index 9721074094f..fa1b7946d69 100644
---
a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
@@ -117,6 +117,25 @@ HNSW_PQ_BUILD_PARAMS = {**HNSW_BUILD_PARAMS,
"num_sub_vectors": 4, "num_bits": 8
# degradation - the regression suite pins that error too.
HNSW_SEARCH_PARAMS = {"ef": 100}
+# multi_frag.lance is the COUNT(*) metadata-pushdown fixture for
test_lance_optimize_count:
+# MULTI_FRAG_NUM_FRAGMENTS fragments of MULTI_FRAG_FRAGMENT_ROWS physical rows
each, with one
+# deleted row per fragment, so the dataset holds MULTI_FRAG_PHYSICAL_ROWS
physical rows on disk
+# but only MULTI_FRAG_LOGICAL_ROWS logical rows after deletions. A COUNT(*)
that reported the
+# physical total would be off by MULTI_FRAG_DELETED_ROWS, so this table is
what proves the
+# pushdown reads Lance's post-deletion row count and that a multi-split scan
applies every
+# fragment's deletion vector exactly once. It carries no index, so unlike the
vector tables its
+# data and every derived count are deterministic (there is no IVF training to
perturb them and
+# no golden ever shifts on regeneration), and Doris discovers it by directory
listing without a
+# __manifest entry (verified against a live FE/BE/MinIO cluster).
+MULTI_FRAG_DIR = "multi_frag.lance"
+MULTI_FRAG_NUM_FRAGMENTS = 3
+MULTI_FRAG_FRAGMENT_ROWS = 10
+MULTI_FRAG_DELETED_ROW_IDS = (5, 15, 25)
+MULTI_FRAG_FILTER_ROW_ID = 15
+MULTI_FRAG_PHYSICAL_ROWS = MULTI_FRAG_NUM_FRAGMENTS * MULTI_FRAG_FRAGMENT_ROWS
+MULTI_FRAG_DELETED_ROWS = len(MULTI_FRAG_DELETED_ROW_IDS)
+MULTI_FRAG_LOGICAL_ROWS = MULTI_FRAG_PHYSICAL_ROWS - MULTI_FRAG_DELETED_ROWS
+
# The boundary query is symmetric for the ladder profiles - rows r-d and r+d
are
# equidistant - so a top-k that lands mid-pair would pin an arbitrary choice
of tie winner
# in the goldens. 9 is the last cut that ends on a complete pair. This is the
regression
@@ -787,8 +806,27 @@ def compact_manifest(root: Path) -> None:
print(f"record: __manifest committed at version {manifest.version}")
+def build_multi_frag(root: Path) -> None:
+ # Reuse make_fragment_table so the row_id/category/label columns and their
NOT NULL mapping
+ # stay identical to the vector tables; multi_frag just drops the embedding
it does not need.
+ location = str(root / MULTI_FRAG_DIR)
+ for index in range(MULTI_FRAG_NUM_FRAGMENTS):
+ offset = index * MULTI_FRAG_FRAGMENT_ROWS
+ fragment = make_fragment_table(offset, offset +
MULTI_FRAG_FRAGMENT_ROWS)
+ fragment = fragment.drop_columns(["embedding"])
+ # Match all_types.lance (data storage version 2.2) so every committed
Lance data file
+ # shares one on-disk format and the oldest reader (lance-rs 4.0.1) can
open it.
+ lance.write_dataset(
+ fragment, location, mode="create" if index == 0 else "append",
+ data_storage_version="2.2",
+ )
+ deleted = ", ".join(str(row_id) for row_id in MULTI_FRAG_DELETED_ROW_IDS)
+ lance.dataset(location).delete(f"row_id in ({deleted})")
+
+
def build(root: Path, all_types_source: Path) -> None:
shutil.copytree(all_types_source, root / ALL_TYPES_DIR)
+ build_multi_frag(root)
namespace = lance_namespace.connect("dir", {"root": str(root)})
namespace.register_table(
RegisterTableRequest(id=["all_types"], location=ALL_TYPES_DIR)
@@ -1431,6 +1469,44 @@ def check_nested_dataset(location: str):
assert probe == [7], f"{NESTED_TABLE}: BTREE probe returned {probe}"
+def check_multi_frag(root: Path) -> None:
+ location = root / MULTI_FRAG_DIR
+ assert location.is_dir(), f"multi_frag location missing: {location}"
+ dataset = lance.dataset(str(location))
+ fragments = dataset.get_fragments()
+ assert len(fragments) == MULTI_FRAG_NUM_FRAGMENTS, (
+ f"multi_frag: expected {MULTI_FRAG_NUM_FRAGMENTS} fragments, got
{len(fragments)}"
+ )
+ for fragment in fragments:
+ metadata = fragment.metadata
+ assert metadata.physical_rows == MULTI_FRAG_FRAGMENT_ROWS, (
+ f"multi_frag fragment {fragment.fragment_id}: physical_rows "
+ f"{metadata.physical_rows} != {MULTI_FRAG_FRAGMENT_ROWS}"
+ )
+ assert metadata.num_deletions == 1, (
+ f"multi_frag fragment {fragment.fragment_id}: expected exactly one
deleted row, "
+ f"got {metadata.num_deletions}"
+ )
+ # The whole point of this table: logical (post-deletion) count, not the
physical total.
+ assert dataset.count_rows() == MULTI_FRAG_LOGICAL_ROWS, (
+ f"multi_frag: logical row count {dataset.count_rows()} !=
{MULTI_FRAG_LOGICAL_ROWS}; "
+ "test_lance_optimize_count asserts COUNT(*) folds to exactly this
number"
+ )
+ surviving =
set(dataset.to_table(columns=["row_id"]).column("row_id").to_pylist())
+ expected = set(range(1, MULTI_FRAG_PHYSICAL_ROWS + 1)) -
set(MULTI_FRAG_DELETED_ROW_IDS)
+ assert surviving == expected, (
+ "multi_frag: surviving row_ids are not the expected
contiguous-minus-deleted set"
+ )
+ # The filtered count in the suite disables the pushdown; keep its golden
derivable here so a
+ # data-shape change fails this self-check instead of only the opaque .out
diff.
+ expected_half = sum(1 for row_id in expected if row_id >
MULTI_FRAG_FILTER_ROW_ID)
+ half = dataset.count_rows(filter=f"row_id > {MULTI_FRAG_FILTER_ROW_ID}")
+ assert half == expected_half, (
+ f"multi_frag: COUNT(*) WHERE row_id > {MULTI_FRAG_FILTER_ROW_ID} is
{half}, not "
+ f"{expected_half}; the filtered-count golden in
test_lance_optimize_count is now stale"
+ )
+
+
def check_catalog(root: Path) -> None:
check_data_shapes()
namespace = lance_namespace.connect("dir", {"root": str(root)})
@@ -1490,6 +1566,7 @@ def check_catalog(root: Path) -> None:
nested_path = Path(nested.location.removeprefix("file://"))
assert nested_path.is_dir(), f"{NESTED_TABLE} location missing:
{nested.location}"
check_nested_dataset(nested.location)
+ check_multi_frag(root)
print(f"self-check OK: {root}")
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/0-3-12903222946140852451.arrow
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/0-3-12903222946140852451.arrow
new file mode 100644
index 00000000000..badfc10c378
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/0-3-12903222946140852451.arrow
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/1-3-3997032015744280171.arrow
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/1-3-3997032015744280171.arrow
new file mode 100644
index 00000000000..badfc10c378
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/1-3-3997032015744280171.arrow
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/2-3-16205658664475652884.arrow
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/2-3-16205658664475652884.arrow
new file mode 100644
index 00000000000..badfc10c378
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_deletions/2-3-16205658664475652884.arrow
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/0-9d78d1d6-4352-45af-81d3-11147a3165c5.txn
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/0-9d78d1d6-4352-45af-81d3-11147a3165c5.txn
new file mode 100644
index 00000000000..70d9f843807
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/0-9d78d1d6-4352-45af-81d3-11147a3165c5.txn
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/1-4ba9d959-a46f-4ecc-bc97-edc7030c82ef.txn
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/1-4ba9d959-a46f-4ecc-bc97-edc7030c82ef.txn
new file mode 100644
index 00000000000..8867f8e63c2
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/1-4ba9d959-a46f-4ecc-bc97-edc7030c82ef.txn
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/2-1b03ecda-1a9c-411c-b9f0-f91af2575aa7.txn
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/2-1b03ecda-1a9c-411c-b9f0-f91af2575aa7.txn
new file mode 100644
index 00000000000..930321f052e
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/2-1b03ecda-1a9c-411c-b9f0-f91af2575aa7.txn
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/3-4b3d53f1-4352-4d80-a8d9-312412d6e5c6.txn
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/3-4b3d53f1-4352-4d80-a8d9-312412d6e5c6.txn
new file mode 100644
index 00000000000..45da071eab9
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_transactions/3-4b3d53f1-4352-4d80-a8d9-312412d6e5c6.txn
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551611.manifest
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551611.manifest
new file mode 100644
index 00000000000..0bdc388ef6f
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551611.manifest
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551612.manifest
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551612.manifest
new file mode 100644
index 00000000000..da761c20840
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551612.manifest
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551613.manifest
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551613.manifest
new file mode 100644
index 00000000000..c2920b2c6cf
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551613.manifest
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551614.manifest
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551614.manifest
new file mode 100644
index 00000000000..9bcdc438927
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/18446744073709551614.manifest
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/latest_version_hint.json
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/latest_version_hint.json
new file mode 100644
index 00000000000..205c7a40a84
--- /dev/null
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/_versions/latest_version_hint.json
@@ -0,0 +1 @@
+{"version":4}
\ No newline at end of file
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/000110011100000010110011ff221e4e9d80211c9e467a875c.lance
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/000110011100000010110011ff221e4e9d80211c9e467a875c.lance
new file mode 100644
index 00000000000..46378d9b5e9
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/000110011100000010110011ff221e4e9d80211c9e467a875c.lance
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/010101101110010111111100e282674630a37f9b31b69c3b3f.lance
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/010101101110010111111100e282674630a37f9b31b69c3b3f.lance
new file mode 100644
index 00000000000..a5dedbec0e5
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/010101101110010111111100e282674630a37f9b31b69c3b3f.lance
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/10000001100001000001100118a7f64f1c805a2e133d8d5c21.lance
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/10000001100001000001100118a7f64f1c805a2e133d8d5c21.lance
new file mode 100644
index 00000000000..8e049a43590
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/multi_frag.lance/data/10000001100001000001100118a7f64f1c805a2e133d8d5c21.lance
differ
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 394cb6ebf06..af26c973163 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
@@ -65,6 +65,11 @@ import java.util.UUID;
* requested snapshot-wide result.
*/
public class LanceScanNode extends FileQueryScanNode {
+ // A metadata COUNT(*) whose result is at least this large is sharded
across several
+ // fragment groups, because BE materializes one synthetic row per counted
row and one carrier
+ // would serialize that O(rowCount) work on a single scanner. Matches
IcebergScanNode.
+ private static final long COUNT_WITH_PARALLEL_SPLITS = 10000;
+
private LanceExternalTable lanceTable;
private LanceTableMetadata plannedMetadata;
private int vectorFieldId = -1;
@@ -151,6 +156,14 @@ public class LanceScanNode extends FileQueryScanNode {
return hasLimit() && conjuncts.isEmpty();
}
+ // COUNT(*)/COUNT(1) can be answered from Lance metadata only when nothing
narrows the row set:
+ // no residual Doris conjunct and no predicate pushed into Lance. Any
filter would make the
+ // dataset-wide logical row count larger than the real result, so this is
stricter than
+ // canPushDownLimit(), which still allows predicates already pushed into
Lance.
+ private boolean canPushDownCountStar() {
+ return isTableLevelCountStarPushdown() && conjuncts.isEmpty() &&
lanceSubstraitFilter.length == 0;
+ }
+
@Override
protected void convertPredicate() {
if (isExternalSearch()) {
@@ -194,6 +207,10 @@ public class LanceScanNode extends FileQueryScanNode {
"Lance vector search requires a fixed positive dataset
version");
}
+ if (canPushDownCountStar()) {
+ return buildCountSplits(metadata, numBackends);
+ }
+
Map<Long, LanceFragmentInfo> visibleFragments =
getVisibleFragments(metadata);
if (isExternalSearch() && shouldUseIndex()) {
Optional<List<Split>> indexSplits =
createIndexSegmentSplits(metadata, visibleFragments);
@@ -204,6 +221,44 @@ public class LanceScanNode extends FileQueryScanNode {
return createFragmentSplits(metadata, visibleFragments);
}
+ // COUNT(*)/COUNT(1) with no filter is answered from Lance metadata. Each
carrier contains a
+ // disjoint fragment group and its logical row count, so a BE that cannot
use the metadata count
+ // falls back to an equivalent fixed-snapshot scan. Large counts use
several carriers to retain
+ // parallelism; small counts use one.
+ private List<Split> buildCountSplits(LanceTableMetadata metadata, int
numBackends) {
+ long rowCount = metadata.getRowCount();
+ setPushDownCount(rowCount);
+ int carrierCount = 1;
+ if (rowCount >= COUNT_WITH_PARALLEL_SPLITS &&
!metadata.getFragments().isEmpty()) {
+ int parallelism =
sessionVariable.getParallelExecInstanceNum(scanContext.getClusterName())
+ * Math.max(numBackends, 1);
+ carrierCount = Math.min(metadata.getFragments().size(),
Math.max(1, parallelism));
+ }
+ List<List<LanceFragmentInfo>> fragmentGroups = new
ArrayList<>(carrierCount);
+ for (int i = 0; i < carrierCount; i++) {
+ fragmentGroups.add(new ArrayList<>());
+ }
+ List<LanceFragmentInfo> fragments = metadata.getFragments();
+ for (int i = 0; i < fragments.size(); i++) {
+ fragmentGroups.get(i % carrierCount).add(fragments.get(i));
+ }
+
+ List<Split> splits = new ArrayList<>(carrierCount);
+ for (List<LanceFragmentInfo> group : fragmentGroups) {
+ List<Long> fragmentIds = new ArrayList<>(group.size());
+ long logicalRows = 0;
+ long physicalRows = 0;
+ for (LanceFragmentInfo fragment : group) {
+ fragmentIds.add(fragment.getId());
+ logicalRows += fragment.getRowCount();
+ physicalRows += fragment.getPhysicalRows();
+ }
+ splits.add(LanceSplit.forCount(metadata.getDatasetUri(),
metadata.getVersion(),
+ fragmentIds, logicalRows, physicalRows));
+ }
+ return splits;
+ }
+
private Map<Long, LanceFragmentInfo>
getVisibleFragments(LanceTableMetadata metadata)
throws UserException {
Map<Long, LanceFragmentInfo> visible = new LinkedHashMap<>();
@@ -369,25 +424,29 @@ public class LanceScanNode extends FileQueryScanNode {
TLanceFileDesc lanceParams = new TLanceFileDesc();
lanceParams.setDatasetUri(lanceSplit.getDatasetUri());
lanceParams.setVersion(lanceSplit.getVersion());
- if (lanceSplit.getFragmentIds().isEmpty()) {
- throw new IllegalArgumentException("Lance scan split must contain
fragments");
- }
- if (!isExternalSearch() && (lanceSplit.getFragmentIds().size() != 1
- || lanceSplit.hasIndexSegmentUuids())) {
- throw new IllegalArgumentException(
- "Ordinary Lance scan split must contain one fragment and
no index segment");
- }
- lanceParams.setFragmentIds(lanceSplit.getFragmentIds());
- if (lanceSplit.hasIndexSegmentUuids()) {
- List<ByteBuffer> uuids = new
ArrayList<>(lanceSplit.getIndexSegmentUuids().size());
- for (UUID uuid : lanceSplit.getIndexSegmentUuids()) {
- ByteBuffer uuidBytes = ByteBuffer.allocate(16);
- uuidBytes.putLong(uuid.getMostSignificantBits());
- uuidBytes.putLong(uuid.getLeastSignificantBits());
- uuidBytes.flip();
- uuids.add(uuidBytes);
+ if (lanceSplit.hasFragmentIds()) {
+ if (!isExternalSearch() && lanceSplit.getTableLevelRowCount() < 0
+ && (lanceSplit.getFragmentIds().size() != 1
+ || lanceSplit.hasIndexSegmentUuids())) {
+ throw new IllegalArgumentException(
+ "Ordinary Lance scan split must contain one fragment
and no index segment");
}
- lanceParams.setIndexSegmentUuids(uuids);
+ lanceParams.setFragmentIds(lanceSplit.getFragmentIds());
+ if (lanceSplit.hasIndexSegmentUuids()) {
+ List<ByteBuffer> uuids = new
ArrayList<>(lanceSplit.getIndexSegmentUuids().size());
+ for (UUID uuid : lanceSplit.getIndexSegmentUuids()) {
+ ByteBuffer uuidBytes = ByteBuffer.allocate(16);
+ uuidBytes.putLong(uuid.getMostSignificantBits());
+ uuidBytes.putLong(uuid.getLeastSignificantBits());
+ uuidBytes.flip();
+ uuids.add(uuidBytes);
+ }
+ lanceParams.setIndexSegmentUuids(uuids);
+ }
+ } else if (lanceSplit.getTableLevelRowCount() < 0) {
+ // Only the metadata COUNT(*) split may omit fragment ids; it
opens no BE scanner and
+ // BE serves the row count from table_level_row_count below,
leaving fragment_ids unset.
+ throw new IllegalArgumentException("Lance scan split must contain
fragments");
}
// Push LIMIT into each ordinary fragment scanner only when it is safe
to truncate that
// fragment early. Vector search uses its own per-split candidate
bound.
@@ -397,6 +456,9 @@ public class LanceScanNode extends FileQueryScanNode {
TTableFormatFileDesc tableFormatParams = new TTableFormatFileDesc();
tableFormatParams.setTableFormatType(TableFormatType.LANCE.value());
+ // Match the Iceberg convention: always set explicitly, -1 for
ordinary and search scans
+ // so BE never mistakes a stale value for a metadata count.
+
tableFormatParams.setTableLevelRowCount(lanceSplit.getTableLevelRowCount());
tableFormatParams.setLanceParams(lanceParams);
rangeDesc.setTableFormatParams(tableFormatParams);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceSplit.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceSplit.java
index 8fbe24c8401..5125ba6c749 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceSplit.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceSplit.java
@@ -36,6 +36,9 @@ public class LanceSplit extends FileSplit {
private final long version;
private final List<Long> fragmentIds;
private final List<UUID> indexSegmentUuids;
+ // Set to a nonnegative value only when this split carries a metadata
COUNT(*) result so BE can
+ // synthesize that many rows instead of scanning fragments. -1 means
ordinary scan.
+ private long tableLevelRowCount = -1;
public static LanceSplit forFragment(
String datasetUri, long version, long fragmentId, long
physicalRows) {
@@ -47,6 +50,16 @@ public class LanceSplit extends FileSplit {
return new LanceSplit(datasetUri, 0, Collections.emptyList(),
Collections.emptyList(), 1);
}
+ // A metadata COUNT(*) carrier pinned to the planned snapshot. Its
fragment range remains valid
+ // input if BE falls back to scanning, while rowCount lets the metadata
path skip that scan.
+ public static LanceSplit forCount(String datasetUri, long version,
List<Long> fragmentIds,
+ long rowCount, long physicalRows) {
+ LanceSplit split = new LanceSplit(
+ datasetUri, version, fragmentIds, Collections.emptyList(),
physicalRows);
+ split.tableLevelRowCount = rowCount;
+ return split;
+ }
+
public static LanceSplit forIndexSegment(String datasetUri, long version,
UUID indexSegmentUuid,
List<Long> fragmentIds, long physicalRows) {
if (fragmentIds == null || fragmentIds.isEmpty()) {
@@ -112,6 +125,10 @@ public class LanceSplit extends FileSplit {
return !indexSegmentUuids.isEmpty();
}
+ public long getTableLevelRowCount() {
+ return tableLevelRowCount;
+ }
+
@Override
public String getConsistentHashString() {
return hasFragmentIds()
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
index 2fb3b7c390d..c2407be03fc 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
@@ -30,6 +30,7 @@ import org.apache.doris.spi.Split;
import org.apache.doris.thrift.TExternalSearchQuery;
import org.apache.doris.thrift.TExternalSearchRequest;
import org.apache.doris.thrift.TFileRangeDesc;
+import org.apache.doris.thrift.TPushAggOp;
import org.apache.doris.thrift.TVectorMetric;
import org.apache.doris.thrift.TVectorSearchOptions;
import org.apache.doris.thrift.TVectorSearchParams;
@@ -94,6 +95,31 @@ public class LanceScanNodeTest {
assertSplit(splits.get(1), 11, 1000, 100);
}
+ @Test
+ public void testCountSplitsPinVersionAndKeepFallbackRangesDisjoint()
throws Exception {
+ LanceTableMetadata metadata = LanceTableMetadata.withoutIndexSegments(
+ "s3://bucket/table.lance",
+ 42,
+ new Schema(Collections.emptyList()),
+ Arrays.asList(
+ new LanceFragmentInfo(7, 6000, 6001),
+ new LanceFragmentInfo(11, 5000, 5001),
+ new LanceFragmentInfo(13, 4000, 4001)),
+ Collections.emptyMap());
+ SessionVariable sessionVariable = new SessionVariable();
+ sessionVariable.parallelExecInstanceNum = 1;
+ LanceScanNode node = newNode(sessionVariable);
+ setMetadata(node, metadata);
+ node.setPushDownAggNoGrouping(TPushAggOp.COUNT);
+ node.setPushDownCountSlotIds(Collections.emptyList());
+
+ List<Split> splits = node.getSplits(2);
+
+ Assert.assertEquals(2, splits.size());
+ assertCountRange(node, splits.get(0), Arrays.asList(7L, 13L), 10_000);
+ assertCountRange(node, splits.get(1), Collections.singletonList(11L),
5_000);
+ }
+
@Test
public void testExternalSearchUsesFragmentSplits() throws Exception {
LanceTableMetadata metadata = LanceTableMetadata.withoutIndexSegments(
@@ -320,11 +346,15 @@ public class LanceScanNodeTest {
}
private static LanceScanNode newNode() {
+ return newNode(new SessionVariable());
+ }
+
+ private static LanceScanNode newNode(SessionVariable sessionVariable) {
return new LanceScanNode(
new PlanNodeId(0),
new TupleDescriptor(new TupleId(0)),
false,
- new SessionVariable(),
+ sessionVariable,
ScanContext.EMPTY);
}
@@ -360,6 +390,20 @@ public class LanceScanNodeTest {
Assert.assertEquals(weight, lanceSplit.getSplitWeight().getRawValue());
}
+ private static void assertCountRange(
+ LanceScanNode node, Split split, List<Long> fragmentIds, long
rowCount) {
+ LanceSplit lanceSplit = (LanceSplit) split;
+ Assert.assertEquals(fragmentIds, lanceSplit.getFragmentIds());
+ Assert.assertEquals(rowCount, lanceSplit.getTableLevelRowCount());
+
+ TFileRangeDesc range = new TFileRangeDesc();
+ node.setScanParams(range, split);
+ Assert.assertEquals(42L,
range.getTableFormatParams().getLanceParams().getVersion());
+ Assert.assertEquals(fragmentIds,
+
range.getTableFormatParams().getLanceParams().getFragmentIds());
+ Assert.assertEquals(rowCount,
range.getTableFormatParams().getTableLevelRowCount());
+ }
+
private static Schema vectorSchema() {
return new Schema(Collections.singletonList(
Field.nullable("vector", ArrowType.Utf8.INSTANCE)));
diff --git
a/regression-test/data/external_table_p0/lance/test_lance_optimize_count.out
b/regression-test/data/external_table_p0/lance/test_lance_optimize_count.out
new file mode 100644
index 00000000000..5bb00ccb27c
--- /dev/null
+++ b/regression-test/data/external_table_p0/lance/test_lance_optimize_count.out
@@ -0,0 +1,31 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !count_star_pushdown --
+12
+
+-- !count_one_pushdown --
+12
+
+-- !count_star_all_rows --
+12
+
+-- !count_star_half --
+6
+
+-- !count_star_no_pushdown --
+12
+
+-- !mf_count_star_pushdown --
+27
+
+-- !mf_count_one_pushdown --
+27
+
+-- !mf_count_all_rows --
+27
+
+-- !mf_count_half --
+14
+
+-- !mf_count_star_no_pushdown --
+27
+
diff --git
a/regression-test/suites/external_table_p0/lance/test_lance_optimize_count.groovy
b/regression-test/suites/external_table_p0/lance/test_lance_optimize_count.groovy
new file mode 100644
index 00000000000..bf0dd7d424f
--- /dev/null
+++
b/regression-test/suites/external_table_p0/lance/test_lance_optimize_count.groovy
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_lance_optimize_count", "p0,external") {
+ /*
+ * COUNT(*)/COUNT(1) with no filter is served from Lance dataset metadata:
+ * FE emits a single split carrying the logical row count and EXPLAIN shows
+ * "pushdown agg=COUNT (<rows>)". Any of the following disables that path
and
+ * falls back to a normal scan ("pushdown agg=NONE"), which must still
return
+ * the same count:
+ * 1. enable_count_push_down_for_external_table = false;
+ * 2. a WHERE filter, because Lance cannot describe COUNT with a
predicate,
+ * so the plan keeps Aggregate(Filter(FileScan)) and never folds into
a
+ * storage-layer aggregate.
+ *
+ * all_types has exactly 12 rows in a single fragment with contiguous,
unique
+ * row_id in [1, 12], so every count below is deterministic.
+ *
+ * multi_frag is the multi-split counterpart: 30 physical rows in three
fragments
+ * with one deleted row per fragment (row_id 5, 15, 25), so its logical
count is 27.
+ * It proves the metadata count reports the logical 27 rather than the
physical 30,
+ * and that a normal multi-split scan applies every fragment's deletion
vector exactly
+ * once (no fragment double-counted or skipped).
+ */
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable Lance count pushdown test because the Iceberg
MinIO environment is disabled.")
+ return
+ }
+
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String catalogName = "test_lance_optimize_count"
+
+ sql """DROP CATALOG IF EXISTS `${catalogName}`"""
+ try {
+ sql """
+ CREATE CATALOG `${catalogName}` PROPERTIES (
+ "type" = "lance",
+ "lance.catalog.type" = "filesystem",
+ "warehouse" = "s3://warehouse/lance",
+ "s3.endpoint" = "http://${externalEnvIp}:${minioPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.region" = "us-east-1",
+ "use_path_style" = "true"
+ )
+ """
+
+ sql """ USE `${catalogName}`.`default`; """
+ // Lance is only served by FileScannerV2, which is where the metadata
count
+ // short-circuit lives.
+ sql """ SET enable_file_scanner_v2 = true; """
+
+ String countStar = """ SELECT count(*) FROM all_types """
+ String countOne = """ SELECT count(1) FROM all_types """
+ String countStarAllRows = """ SELECT count(*) FROM all_types WHERE
row_id > 0 """
+ String countStarHalf = """ SELECT count(*) FROM all_types WHERE row_id
> 6 """
+
+ // ---- Pushdown ON (the optimization) ----
+ sql """ SET enable_count_push_down_for_external_table = true; """
+
+ // No filter: COUNT(*) and COUNT(1) both fold into the metadata count.
+ explain {
+ sql(countStar)
+ contains "pushdown agg=COUNT (12)"
+ }
+ explain {
+ sql(countOne)
+ contains "pushdown agg=COUNT (12)"
+ }
+ qt_count_star_pushdown """${countStar}"""
+ qt_count_one_pushdown """${countOne}"""
+
+ // A filter keeps the aggregate above the scan, so no metadata count.
+ explain {
+ sql(countStarHalf)
+ contains "pushdown agg=NONE"
+ }
+ qt_count_star_all_rows """${countStarAllRows}"""
+ qt_count_star_half """${countStarHalf}"""
+
+ // ---- Pushdown OFF (the baseline before the optimization) ----
+ sql """ SET enable_count_push_down_for_external_table = false; """
+
+ explain {
+ sql(countStar)
+ contains "pushdown agg=NONE"
+ }
+ // Same result whether or not the metadata count is used.
+ qt_count_star_no_pushdown """${countStar}"""
+
+ // ---- Multi-fragment table with deletions (three splits, logical
count 27) ----
+ String mfCountStar = """ SELECT count(*) FROM multi_frag """
+ String mfCountOne = """ SELECT count(1) FROM multi_frag """
+ // Filter keeps > 12 rows so it cannot be confused with all_types'
count.
+ String mfCountHalf = """ SELECT count(*) FROM multi_frag WHERE row_id
> 15 """
+ // A whole-table filter must still equal the metadata count: it
exercises the
+ // multi-split scan path (deletion vectors applied per fragment)
instead of the
+ // metadata short-circuit, and the two must agree.
+ String mfCountAll = """ SELECT count(*) FROM multi_frag WHERE row_id >
0 """
+
+ sql """ SET enable_count_push_down_for_external_table = true; """
+
+ // No filter: the single metadata split reports the logical 27, not
the physical 30.
+ explain {
+ sql(mfCountStar)
+ contains "pushdown agg=COUNT (27)"
+ }
+ explain {
+ sql(mfCountOne)
+ contains "pushdown agg=COUNT (27)"
+ }
+ qt_mf_count_star_pushdown """${mfCountStar}"""
+ qt_mf_count_one_pushdown """${mfCountOne}"""
+
+ // The metadata count carrier must pin the planned dataset version,
not latest (version 0):
+ // a fallback scan (an old BE, or a BE that declines the shortcut) has
to read the same
+ // snapshot the count came from. multi_frag is built with three
appends and three deletes,
+ // so its planned version is 4. A regression to wholeDatasetAtLatest()
would print
+ // "lanceVersion=0" here and let time-travel / concurrent-commit reads
drift.
+ explain {
+ sql(mfCountStar)
+ contains "lanceVersion=4"
+ }
+
+ // Filtered counts fall back to the three-split scan and must still be
exact.
+ explain {
+ sql(mfCountHalf)
+ contains "pushdown agg=NONE"
+ }
+ qt_mf_count_all_rows """${mfCountAll}"""
+ qt_mf_count_half """${mfCountHalf}"""
+
+ // Off switch: the same multi-split scan must reproduce the logical 27.
+ sql """ SET enable_count_push_down_for_external_table = false; """
+ explain {
+ sql(mfCountStar)
+ contains "pushdown agg=NONE"
+ }
+ qt_mf_count_star_no_pushdown """${mfCountStar}"""
+ } finally {
+ sql """ SET enable_count_push_down_for_external_table = true; """
+ sql """DROP CATALOG IF EXISTS `${catalogName}`"""
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]