This is an automated email from the ASF dual-hosted git repository.

lzljs3620320 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 1154bdb  feat: support limit push down in datafusion (#177)
1154bdb is described below

commit 1154bdbe71341d592238933c7127c1338fb4e79a
Author: yuxia Luo <[email protected]>
AuthorDate: Fri Apr 3 14:46:19 2026 +0800

    feat: support limit push down in datafusion (#177)
---
 crates/integration_tests/tests/read_tables.rs      | 52 +++++++++++++
 .../datafusion/src/physical_plan/scan.rs           | 52 +++++++++++--
 crates/integrations/datafusion/src/table/mod.rs    |  8 +-
 .../integrations/datafusion/tests/read_tables.rs   | 48 ++++++++++++
 crates/paimon/src/table/read_builder.rs            | 17 ++++-
 crates/paimon/src/table/source.rs                  | 86 +++++++++++++++++++++-
 crates/paimon/src/table/table_scan.rs              | 66 ++++++++++++++++-
 7 files changed, 317 insertions(+), 12 deletions(-)

diff --git a/crates/integration_tests/tests/read_tables.rs 
b/crates/integration_tests/tests/read_tables.rs
index 090197d..7027110 100644
--- a/crates/integration_tests/tests/read_tables.rs
+++ b/crates/integration_tests/tests/read_tables.rs
@@ -947,3 +947,55 @@ async fn test_read_data_evolution_table_with_projection() {
         "Projected data evolution read should return correct values"
     );
 }
+
+// ---------------------------------------------------------------------------
+// Limit pushdown integration tests
+// ---------------------------------------------------------------------------
+
+/// Helper function to scan and read with limit pushdown.
+async fn plan_table(table: &paimon::Table, limit: Option<usize>) -> Plan {
+    let mut read_builder = table.new_read_builder();
+    if let Some(limit) = limit {
+        read_builder.with_limit(limit);
+    }
+    let scan = read_builder.new_scan();
+    scan.plan().await.expect("Failed to plan scan")
+}
+
+/// Test limit pushdown: when limit is smaller than total rows, fewer data 
files may be generated.
+#[tokio::test]
+async fn test_limit_pushdown() {
+    let catalog = create_file_system_catalog();
+
+    // Test limit pushdown for data evolution table
+    let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
+
+    // Get full plan without limit
+    let full_plan = plan_table(&table, None).await;
+    let full_data_split_count: usize = full_plan.splits().iter().count();
+
+    // Get the plan with limit = 2
+    let limited_plan = plan_table(&table, Some(2)).await;
+    let limited_data_split_count: usize = limited_plan.splits().iter().count();
+
+    // For data evolution tables, limit pushdown at split level uses 
merged_row_count
+    // The limited data split count should be < full data split count
+    assert!(
+        limited_data_split_count < full_data_split_count,
+        "Limit pushdown should reduce data split count for data evolution 
table: limited={limited_data_split_count}, full={full_data_split_count}"
+    );
+
+    // Verify data evolution splits have merged_row_count
+    for split in full_plan.splits() {
+        let merged_count = split.merged_row_count().expect(
+            "Data evolution table should have merged_row_count (all files 
should have first_row_id)",
+        );
+        // merged_row_count should be < row_count (overlapping ranges reduce 
count)
+        assert!(
+            merged_count < split.row_count(),
+            "merged_row_count ({}) should be < row_count ({})",
+            merged_count,
+            split.row_count()
+        );
+    }
+}
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index 52f5357..b047d3a 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -16,8 +16,10 @@
 // under the License.
 
 use std::any::Any;
+use std::pin::Pin;
 use std::sync::Arc;
 
+use datafusion::arrow::array::RecordBatch;
 use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
 use datafusion::error::Result as DFResult;
 use datafusion::execution::{SendableRecordBatchStream, TaskContext};
@@ -25,7 +27,7 @@ use datafusion::physical_expr::EquivalenceProperties;
 use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
 use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
 use datafusion::physical_plan::{DisplayAs, ExecutionPlan, Partitioning, 
PlanProperties};
-use futures::{StreamExt, TryStreamExt};
+use futures::{Stream, StreamExt, TryStreamExt};
 use paimon::table::Table;
 use paimon::DataSplit;
 
@@ -46,6 +48,8 @@ pub struct PaimonTableScan {
     /// Wrapped in `Arc` to avoid deep-cloning `DataSplit` metadata in 
`execute()`.
     planned_partitions: Vec<Arc<[DataSplit]>>,
     plan_properties: PlanProperties,
+    /// Optional limit on the number of rows to return.
+    limit: Option<usize>,
 }
 
 impl PaimonTableScan {
@@ -54,6 +58,7 @@ impl PaimonTableScan {
         table: Table,
         projected_columns: Option<Vec<String>>,
         planned_partitions: Vec<Arc<[DataSplit]>>,
+        limit: Option<usize>,
     ) -> Self {
         let plan_properties = PlanProperties::new(
             EquivalenceProperties::new(schema.clone()),
@@ -66,6 +71,7 @@ impl PaimonTableScan {
             projected_columns,
             planned_partitions,
             plan_properties,
+            limit,
         }
     }
 
@@ -77,6 +83,10 @@ impl PaimonTableScan {
     pub(crate) fn planned_partitions(&self) -> &[Arc<[DataSplit]>] {
         &self.planned_partitions
     }
+
+    pub fn limit(&self) -> Option<usize> {
+        self.limit
+    }
 }
 
 impl ExecutionPlan for PaimonTableScan {
@@ -118,6 +128,7 @@ impl ExecutionPlan for PaimonTableScan {
         let table = self.table.clone();
         let schema = self.schema();
         let projected_columns = self.projected_columns.clone();
+        let limit = self.limit;
 
         let fut = async move {
             let mut read_builder = table.new_read_builder();
@@ -138,9 +149,30 @@ impl ExecutionPlan for PaimonTableScan {
         };
 
         let stream = futures::stream::once(fut).try_flatten();
+
+        // Enforce the final LIMIT at the DataFusion execution layer.
+        let limited_stream: Pin<Box<dyn Stream<Item = DFResult<RecordBatch>> + 
Send>> =
+            if let Some(limit) = limit {
+                let mut remaining = limit;
+                Box::pin(stream.try_filter_map(move |batch| {
+                    futures::future::ready(if remaining == 0 {
+                        Ok(None)
+                    } else if batch.num_rows() <= remaining {
+                        remaining -= batch.num_rows();
+                        Ok(Some(batch))
+                    } else {
+                        let limited_batch = batch.slice(0, remaining);
+                        remaining = 0;
+                        Ok(Some(limited_batch))
+                    })
+                }))
+            } else {
+                Box::pin(stream)
+            };
+
         Ok(Box::pin(RecordBatchStreamAdapter::new(
             self.schema(),
-            stream,
+            limited_stream,
         )))
     }
 }
@@ -155,7 +187,11 @@ impl DisplayAs for PaimonTableScan {
             f,
             "PaimonTableScan: partitions={}",
             self.planned_partitions.len()
-        )
+        )?;
+        if let Some(limit) = self.limit {
+            write!(f, ", limit={limit}")?;
+        }
+        Ok(())
     }
 }
 
@@ -176,7 +212,13 @@ mod tests {
     #[test]
     fn test_partition_count_empty_plan() {
         let schema = test_schema();
-        let scan = PaimonTableScan::new(schema, dummy_table(), None, 
vec![Arc::from(Vec::new())]);
+        let scan = PaimonTableScan::new(
+            schema,
+            dummy_table(),
+            None,
+            vec![Arc::from(Vec::new())],
+            None,
+        );
         assert_eq!(scan.properties().output_partitioning().partition_count(), 
1);
     }
 
@@ -188,7 +230,7 @@ mod tests {
             Arc::from(Vec::new()),
             Arc::from(Vec::new()),
         ];
-        let scan = PaimonTableScan::new(schema, dummy_table(), None, 
planned_partitions);
+        let scan = PaimonTableScan::new(schema, dummy_table(), None, 
planned_partitions, None);
         assert_eq!(scan.properties().output_partitioning().partition_count(), 
3);
     }
 
diff --git a/crates/integrations/datafusion/src/table/mod.rs 
b/crates/integrations/datafusion/src/table/mod.rs
index 6ea4803..73763e5 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -100,7 +100,7 @@ impl TableProvider for PaimonTableProvider {
         state: &dyn Session,
         projection: Option<&Vec<usize>>,
         filters: &[Expr],
-        _limit: Option<usize>,
+        limit: Option<usize>,
     ) -> DFResult<Arc<dyn ExecutionPlan>> {
         // Convert projection indices to column names and compute projected 
schema
         let (projected_schema, projected_columns) = if let Some(indices) = 
projection {
@@ -119,6 +119,11 @@ impl TableProvider for PaimonTableProvider {
         if let Some(filter) = build_pushed_predicate(filters, 
self.table.schema().fields()) {
             read_builder.with_filter(filter);
         }
+        // Push the limit hint to paimon-core planning to reduce splits when 
possible.
+        // DataFusion still enforces the final LIMIT semantics.
+        if let Some(limit) = limit {
+            read_builder.with_limit(limit);
+        }
         let scan = read_builder.new_scan();
         let plan = scan.plan().await.map_err(to_datafusion_error)?;
 
@@ -143,6 +148,7 @@ impl TableProvider for PaimonTableProvider {
             self.table.clone(),
             projected_columns,
             planned_partitions,
+            limit,
         )))
     }
 }
diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index 68ed58f..89f0dd0 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -286,6 +286,54 @@ async fn 
test_mixed_and_filter_keeps_residual_datafusion_filter() {
     assert_eq!(actual_rows, vec![(2, "bob".to_string())]);
 }
 
+/// Test limit pushdown: ensures that LIMIT queries return the correct number 
of rows.
+#[tokio::test]
+async fn test_limit_pushdown() {
+    // Test append-only table (simple_log_table)
+    {
+        let batches = collect_query(
+            "simple_log_table",
+            "SELECT id, name FROM simple_log_table LIMIT 2",
+        )
+        .await
+        .expect("Limit query should succeed");
+
+        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(total_rows, 2, "LIMIT 2 should return exactly 2 rows");
+    }
+
+    // Test data evolution table
+    {
+        let batches = collect_query(
+            "data_evolution_table",
+            "SELECT id, name FROM data_evolution_table LIMIT 3",
+        )
+        .await
+        .expect("Limit query on data evolution table should succeed");
+
+        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(
+            total_rows, 3,
+            "LIMIT 3 should return exactly 3 rows for data evolution table"
+        );
+
+        // Verify the data is from the merged result (not raw files)
+        let mut rows = extract_id_name_rows(&batches);
+        rows.sort_by_key(|(id, _)| *id);
+
+        // LIMIT 3 returns ids 1, 2, 3 with merged values
+        assert_eq!(
+            rows,
+            vec![
+                (1, "alice-v2".to_string()),
+                (2, "bob".to_string()),
+                (3, "carol-v2".to_string()),
+            ],
+            "Data evolution table LIMIT 3 should return merged rows"
+        );
+    }
+}
+
 // ======================= Catalog Provider Tests =======================
 #[tokio::test]
 async fn test_query_via_catalog_provider() {
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 65c89b8..884d7e4 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -36,6 +36,7 @@ pub struct ReadBuilder<'a> {
     table: &'a Table,
     projected_fields: Option<Vec<String>>,
     filter: Option<Predicate>,
+    limit: Option<usize>,
 }
 
 impl<'a> ReadBuilder<'a> {
@@ -44,6 +45,7 @@ impl<'a> ReadBuilder<'a> {
             table,
             projected_fields: None,
             filter: None,
+            limit: None,
         }
     }
 
@@ -72,9 +74,22 @@ impl<'a> ReadBuilder<'a> {
         self
     }
 
+    /// Push a row-limit hint down to scan planning.
+    ///
+    /// This allows the scan to generate fewer splits when possible. The hint 
is
+    /// applied based on the `merged_row_count()` of each split.
+    ///
+    /// Note: This method does not guarantee that exactly `limit` rows will be
+    /// returned by [`TableRead`]. It is only a pushdown hint for planning.
+    /// Callers or query engines are responsible for enforcing the final LIMIT.
+    pub fn with_limit(&mut self, limit: usize) -> &mut Self {
+        self.limit = Some(limit);
+        self
+    }
+
     /// Create a table scan. Call [TableScan::plan] to get splits.
     pub fn new_scan(&self) -> TableScan<'a> {
-        TableScan::new(self.table, self.filter.clone())
+        TableScan::new(self.table, self.filter.clone(), self.limit)
     }
 
     /// Create a table read for consuming splits (e.g. from a scan plan).
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 7416ca9..9e96b5b 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -22,8 +22,8 @@
 #![allow(dead_code)]
 
 use crate::spec::{BinaryRow, DataFileMeta};
+use crate::table::table_scan::group_by_overlapping_row_id;
 use serde::{Deserialize, Serialize};
-
 // ======================= DeletionFile ===============================
 
 /// Deletion file for a data file: describes a region in a file that stores 
deletion vector bitmap.
@@ -185,6 +185,90 @@ impl DataSplit {
         self.data_files.iter().map(|f| f.row_count).sum()
     }
 
+    /// Returns the merged row count if it can be computed.
+    ///
+    /// This method tries two approaches in order:
+    ///
+    /// 1. **Raw merged row count** (`raw_convertible = true`):
+    ///    - row_count() - total deleted rows (if deletion files have 
cardinality)
+    ///    - row_count() (if no deletion files)
+    ///
+    /// 2. **Data evolution merged row count** (`raw_convertible = false`):
+    ///    - Requires all files to have `first_row_id` set
+    ///    - Merges overlapping row ID ranges and takes max row count per group
+    ///
+    /// Returns `None` if:
+    /// - `raw_convertible = false` and not all files have `first_row_id`
+    /// - Deletion files exist but cardinality is unknown
+    ///
+    /// Reference: 
[DataSplit.mergedRowCount()](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java#L133)
+    pub fn merged_row_count(&self) -> Option<i64> {
+        // Try raw merged row count first (for raw_convertible splits)
+        if let Some(count) = self.raw_merged_row_count() {
+            return Some(count);
+        }
+
+        // Try data evolution merged row count
+        self.data_evolution_merged_row_count()
+    }
+
+    /// Check if raw merged row count is available and compute it.
+    ///
+    /// Available when:
+    /// - `raw_convertible = true`
+    /// - All deletion files have known cardinality (or no deletion files)
+    fn raw_merged_row_count(&self) -> Option<i64> {
+        if !self.raw_convertible {
+            return None;
+        }
+
+        match &self.data_deletion_files {
+            None => {
+                // No deletion files, merged count = row count
+                Some(self.row_count())
+            }
+            Some(deletion_files) => {
+                let mut total = 0i64;
+                for (i, file) in self.data_files.iter().enumerate() {
+                    let deleted_count = match 
deletion_files.get(i).and_then(|df| df.as_ref()) {
+                        None => 0,
+                        Some(df) => df.cardinality()?,
+                    };
+                    total += file.row_count - deleted_count;
+                }
+                Some(total)
+            }
+        }
+    }
+
+    /// Check if data evolution merged row count is available and compute it.
+    ///
+    /// Available when all files have `first_row_id` set. This is used for
+    /// data evolution mode where files may have overlapping row ID ranges.
+    ///
+    /// The algorithm merges overlapping ranges and takes the max row count
+    /// from each group (since overlapping files share some rows).
+    ///
+    /// Reference: 
[DataSplit.dataEvolutionMergedRowCount()](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java#L174)
+    fn data_evolution_merged_row_count(&self) -> Option<i64> {
+        // Check all files have first_row_id
+        if self.data_files.iter().any(|f| f.first_row_id.is_none()) {
+            return None;
+        }
+
+        if self.data_files.is_empty() {
+            return Some(0);
+        }
+
+        // Merge overlapping row ID ranges and compute max row_count per group
+        let groups = group_by_overlapping_row_id(self.data_files.to_vec());
+        let sum: i64 = groups
+            .iter()
+            .map(|group| group.iter().map(|f| f.row_count).max().unwrap_or(0))
+            .sum();
+        Some(sum)
+    }
+
     pub fn builder() -> DataSplitBuilder {
         DataSplitBuilder::new()
     }
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 9992e0b..3988a17 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -27,7 +27,7 @@ use crate::spec::{
     IndexManifest, ManifestEntry, PartitionComputer, Predicate, Snapshot,
 };
 use crate::table::bin_pack::split_for_batch;
-use crate::table::source::{DataSplitBuilder, DeletionFile, PartitionBucket, 
Plan};
+use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile, 
PartitionBucket, Plan};
 use crate::table::SnapshotManager;
 use crate::Error;
 use std::collections::{HashMap, HashSet};
@@ -173,7 +173,7 @@ fn partition_matches_predicate(
 /// Files without `first_row_id` become their own group.
 ///
 /// Reference: 
[DataEvolutionSplitGenerator](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/splitread/DataEvolutionSplitGenerator.java)
-fn group_by_overlapping_row_id(mut files: Vec<DataFileMeta>) -> 
Vec<Vec<DataFileMeta>> {
+pub(crate) fn group_by_overlapping_row_id(mut files: Vec<DataFileMeta>) -> 
Vec<Vec<DataFileMeta>> {
     files.sort_by(|a, b| {
         let a_row_id = a.first_row_id.unwrap_or(i64::MIN);
         let b_row_id = b.first_row_id.unwrap_or(i64::MIN);
@@ -226,11 +226,18 @@ fn group_by_overlapping_row_id(mut files: 
Vec<DataFileMeta>) -> Vec<Vec<DataFile
 pub struct TableScan<'a> {
     table: &'a Table,
     filter: Option<Predicate>,
+    /// Optional limit on the number of rows to return.
+    /// When set, the scan will try to return only enough splits to satisfy 
the limit.
+    limit: Option<usize>,
 }
 
 impl<'a> TableScan<'a> {
-    pub fn new(table: &'a Table, filter: Option<Predicate>) -> Self {
-        Self { table, filter }
+    pub fn new(table: &'a Table, filter: Option<Predicate>, limit: 
Option<usize>) -> Self {
+        Self {
+            table,
+            filter,
+            limit,
+        }
     }
 
     /// Plan the full scan: read latest snapshot, manifest list, manifest 
entries, then build DataSplits using bin packing.
@@ -246,6 +253,53 @@ impl<'a> TableScan<'a> {
         self.plan_snapshot(snapshot).await
     }
 
+    /// Apply a limit-pushdown hint to the generated splits.
+    ///
+    /// Iterates through splits and accumulates `merged_row_count()` until the
+    /// limit hint is reached. Returns only the splits likely needed to satisfy
+    /// that hint.
+    ///
+    /// This does not guarantee an exact final row count. If a split's
+    /// `merged_row_count()` is `None` (for example because of unknown deletion
+    /// cardinality), that split is kept even though its contribution to the
+    /// limit is unknown. Planning may still stop early later if the
+    /// accumulated known `merged_row_count()` reaches the limit, and the
+    /// caller or query engine must enforce the final LIMIT.
+    fn apply_limit_pushdown(&self, splits: Vec<DataSplit>) -> Vec<DataSplit> {
+        let limit = match self.limit {
+            Some(l) => l,
+            None => return splits,
+        };
+
+        if splits.is_empty() {
+            return splits;
+        }
+
+        let mut limited_splits = Vec::new();
+        let mut scanned_row_count: i64 = 0;
+
+        for split in splits {
+            match split.merged_row_count() {
+                Some(merged_count) => {
+                    limited_splits.push(split);
+                    scanned_row_count += merged_count;
+                    if scanned_row_count >= limit as i64 {
+                        // We likely have enough rows for the limit hint.
+                        return limited_splits;
+                    }
+                }
+                None => {
+                    // Can't compute merged row count, so keep this split and
+                    // rely on the caller or query engine to enforce the final
+                    // LIMIT.
+                    limited_splits.push(split);
+                }
+            }
+        }
+
+        limited_splits
+    }
+
     async fn plan_snapshot(&self, snapshot: Snapshot) -> crate::Result<Plan> {
         let file_io = self.table.file_io();
         let table_path = self.table.location();
@@ -425,6 +479,10 @@ impl<'a> TableScan<'a> {
                 splits.push(builder.build()?);
             }
         }
+
+        // Apply limit pushdown to reduce the number of splits if possible
+        let splits = self.apply_limit_pushdown(splits);
+
         Ok(Plan::new(splits))
     }
 }

Reply via email to