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

JingsongLi 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 2760b14  feat: support batch incremental delta scans (#508)
2760b14 is described below

commit 2760b149ea644951ea5a05342e6269a5d0f5bbf8
Author: Pandas <[email protected]>
AuthorDate: Mon Jul 13 13:08:54 2026 +0800

    feat: support batch incremental delta scans (#508)
---
 crates/paimon/src/lib.rs                           |   7 +-
 crates/paimon/src/table/format_read_builder.rs     |   4 +
 crates/paimon/src/table/incremental_scan.rs        | 219 +++++++++++++++++
 crates/paimon/src/table/mod.rs                     |   4 +
 crates/paimon/src/table/read_builder.rs            |  27 +++
 crates/paimon/src/table/table_read.rs              |  45 ++++
 crates/paimon/src/table/table_scan.rs              | 156 +++++++++++-
 crates/paimon/tests/common/incremental_helpers.rs  | 142 +++++++++++
 crates/paimon/tests/common/mod.rs                  |  20 ++
 crates/paimon/tests/incremental_batch_scan_test.rs | 267 +++++++++++++++++++++
 10 files changed, 885 insertions(+), 6 deletions(-)

diff --git a/crates/paimon/src/lib.rs b/crates/paimon/src/lib.rs
index d9cd290..ddf207d 100644
--- a/crates/paimon/src/lib.rs
+++ b/crates/paimon/src/lib.rs
@@ -47,9 +47,10 @@ pub use catalog::FileSystemCatalog;
 
 pub use table::{
     CommitMessage, DataEvolutionDeleteWriter, DataEvolutionWriter, DataSplit, 
DataSplitBuilder,
-    DeletionFile, PartitionBucket, Plan, RESTEnv, RESTSnapshotCommit, 
ReadBuilder,
-    RenamingSnapshotCommit, RowRange, ScanTrace, SnapshotCommit, 
SnapshotManager, Table,
-    TableCommit, TableRead, TableScan, TableUpdate, TableWrite, TagManager, 
WriteBuilder,
+    DeletionFile, IncrementalPlan, IncrementalScan, IncrementalScanMode, 
IncrementalSplit,
+    PartitionBucket, Plan, RESTEnv, RESTSnapshotCommit, ReadBuilder, 
RenamingSnapshotCommit,
+    RowRange, ScanTrace, SnapshotCommit, SnapshotManager, Table, TableCommit, 
TableRead, TableScan,
+    TableUpdate, TableWrite, TagManager, WriteBuilder,
 };
 
 pub use table::{
diff --git a/crates/paimon/src/table/format_read_builder.rs 
b/crates/paimon/src/table/format_read_builder.rs
index 06acf51..f44d367 100644
--- a/crates/paimon/src/table/format_read_builder.rs
+++ b/crates/paimon/src/table/format_read_builder.rs
@@ -45,6 +45,10 @@ impl<'a> FormatReadBuilder<'a> {
         }
     }
 
+    pub(crate) fn table(&self) -> &'a Table {
+        self.table
+    }
+
     pub(crate) fn with_projection(&mut self, columns: &[&str]) -> Result<&mut 
Self> {
         let projection_names = columns.iter().map(|c| 
(*c).to_string()).collect::<Vec<_>>();
         self.read_type = Some(resolve_projected_fields(
diff --git a/crates/paimon/src/table/incremental_scan.rs 
b/crates/paimon/src/table/incremental_scan.rs
new file mode 100644
index 0000000..8088d82
--- /dev/null
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -0,0 +1,219 @@
+// 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.
+
+use super::{DataSplit, SnapshotManager, Table, TableScan};
+use crate::spec::{CommitKind, CoreOptions};
+
+/// Batch incremental scan mode.
+///
+/// Range semantics: `(start_exclusive, end_inclusive]` — start is exclusive 
and
+/// end is inclusive. An empty range (`start == end`) yields an empty plan.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IncrementalScanMode {
+    /// Read data files from APPEND snapshots in the range (delta manifests).
+    Delta,
+    /// Read changelog manifest files in the range.
+    ///
+    /// Not fully implemented in this release; planning returns
+    /// [`Error::Unsupported`](crate::Error::Unsupported).
+    Changelog,
+    /// Resolve to [`Delta`](Self::Delta) when `changelog-producer=none`,
+    /// otherwise to [`Changelog`](Self::Changelog).
+    Auto,
+    /// Diff before/after snapshots.
+    ///
+    /// Not fully implemented in this release; planning returns
+    /// [`Error::Unsupported`](crate::Error::Unsupported).
+    Diff,
+}
+
+/// A unit of work produced by an incremental plan.
+#[derive(Debug, Clone)]
+pub enum IncrementalSplit {
+    Data(DataSplit),
+    /// Per-(partition, bucket) diff pair. Memory bounded by one bucket's data.
+    DiffPair {
+        before: Vec<DataSplit>,
+        after: Vec<DataSplit>,
+    },
+}
+
+/// Planned incremental scan: resolved mode plus splits.
+#[derive(Debug, Clone)]
+pub struct IncrementalPlan {
+    mode: IncrementalScanMode,
+    splits: Vec<IncrementalSplit>,
+}
+
+impl IncrementalPlan {
+    pub fn new(mode: IncrementalScanMode, splits: Vec<IncrementalSplit>) -> 
Self {
+        Self { mode, splits }
+    }
+
+    /// Resolved mode (`Auto` already collapsed to `Delta` / `Changelog`).
+    pub fn mode(&self) -> IncrementalScanMode {
+        self.mode
+    }
+
+    pub fn splits(&self) -> &[IncrementalSplit] {
+        &self.splits
+    }
+
+    pub fn data_splits(&self) -> Vec<DataSplit> {
+        self.splits
+            .iter()
+            .filter_map(|split| match split {
+                IncrementalSplit::Data(data) => Some(data.clone()),
+                IncrementalSplit::DiffPair { .. } => None,
+            })
+            .collect()
+    }
+}
+
+/// Batch incremental scan over a snapshot id range.
+pub struct IncrementalScan<'a> {
+    table: &'a Table,
+    scan: TableScan<'a>,
+    snapshot_manager: SnapshotManager,
+    mode: IncrementalScanMode,
+    start_exclusive: i64,
+    end_inclusive: i64,
+}
+
+impl<'a> IncrementalScan<'a> {
+    pub(crate) fn for_table(
+        table: &'a Table,
+        mode: IncrementalScanMode,
+        start_exclusive: i64,
+        end_inclusive: i64,
+    ) -> Self {
+        let scan = TableScan::new(table, None, Vec::new(), None, None, None);
+        Self::new(table, scan, mode, start_exclusive, end_inclusive)
+    }
+
+    pub(crate) fn new(
+        table: &'a Table,
+        scan: TableScan<'a>,
+        mode: IncrementalScanMode,
+        start_exclusive: i64,
+        end_inclusive: i64,
+    ) -> Self {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        Self {
+            table,
+            scan,
+            snapshot_manager,
+            mode,
+            start_exclusive,
+            end_inclusive,
+        }
+    }
+
+    pub async fn plan(&self) -> crate::Result<IncrementalPlan> {
+        let mode = self.resolve_mode();
+        self.validate_snapshot_range(mode).await?;
+        if self.start_exclusive == self.end_inclusive {
+            return Ok(IncrementalPlan::new(mode, Vec::new()));
+        }
+        match mode {
+            IncrementalScanMode::Delta => self.plan_delta(mode).await,
+            IncrementalScanMode::Changelog => self.plan_changelog(mode).await,
+            IncrementalScanMode::Auto => unreachable!("Auto must resolve 
before planning"),
+            IncrementalScanMode::Diff => self.plan_diff(mode).await,
+        }
+    }
+
+    fn resolve_mode(&self) -> IncrementalScanMode {
+        match self.mode {
+            IncrementalScanMode::Auto => {
+                let core_options = 
CoreOptions::new(self.table.schema().options());
+                let producer = core_options.changelog_producer();
+                if producer.eq_ignore_ascii_case("none") {
+                    IncrementalScanMode::Delta
+                } else {
+                    IncrementalScanMode::Changelog
+                }
+            }
+            mode => mode,
+        }
+    }
+
+    async fn validate_snapshot_range(&self, mode: IncrementalScanMode) -> 
crate::Result<()> {
+        let earliest = self
+            .snapshot_manager
+            .earliest_snapshot_id()
+            .await?
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "No snapshots available for incremental 
scan".to_string(),
+                source: None,
+            })?;
+        let latest = self
+            .snapshot_manager
+            .get_latest_snapshot_id()
+            .await?
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "No snapshots available for incremental 
scan".to_string(),
+                source: None,
+            })?;
+        let min_start = match mode {
+            IncrementalScanMode::Diff => earliest,
+            IncrementalScanMode::Delta | IncrementalScanMode::Changelog => 
earliest - 1,
+            IncrementalScanMode::Auto => unreachable!("Auto must resolve 
before validation"),
+        };
+        if self.start_exclusive < min_start
+            || self.end_inclusive > latest
+            || self.start_exclusive > self.end_inclusive
+        {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Incremental snapshot range [{}, {}] is out of available 
range [{}, {}] for {:?}",
+                    self.start_exclusive, self.end_inclusive, min_start, 
latest, mode
+                ),
+                source: None,
+            });
+        }
+        Ok(())
+    }
+
+    async fn plan_delta(&self, mode: IncrementalScanMode) -> 
crate::Result<IncrementalPlan> {
+        let mut splits = Vec::new();
+        for snapshot_id in (self.start_exclusive + 1)..=self.end_inclusive {
+            let snapshot = 
self.snapshot_manager.get_snapshot(snapshot_id).await?;
+            if snapshot.commit_kind() != &CommitKind::APPEND {
+                continue;
+            }
+            let plan = self.scan.plan_snapshot_delta(&snapshot).await?;
+            
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
+        }
+        Ok(IncrementalPlan::new(mode, splits))
+    }
+
+    async fn plan_changelog(&self, mode: IncrementalScanMode) -> 
crate::Result<IncrementalPlan> {
+        let _ = mode;
+        Err(crate::Error::Unsupported {
+            message: "Batch incremental Changelog scan is not implemented 
yet".to_string(),
+        })
+    }
+
+    async fn plan_diff(&self, mode: IncrementalScanMode) -> 
crate::Result<IncrementalPlan> {
+        let _ = mode;
+        Err(crate::Error::Unsupported {
+            message: "Batch incremental Diff scan is not implemented 
yet".to_string(),
+        })
+    }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 96e9e43..e454e98 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -48,6 +48,7 @@ mod global_index_drop_builder;
 pub(crate) mod global_index_scanner;
 mod global_index_types;
 mod hybrid_search_builder;
+mod incremental_scan;
 mod kv_file_reader;
 mod kv_file_writer;
 mod lumina_index_build_builder;
@@ -94,6 +95,9 @@ pub use global_index_types::{
 pub use hybrid_search_builder::{
     HybridSearchBuilder, HybridSearchRanker, HybridSearchRoute, 
HybridSearchRouteKind,
 };
+pub use incremental_scan::{
+    IncrementalPlan, IncrementalScan, IncrementalScanMode, IncrementalSplit,
+};
 pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
 pub use read_builder::ReadBuilder;
 pub use rest_env::RESTEnv;
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 8bca5a7..a0fc6d7 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -22,6 +22,7 @@
 
 use super::bucket_filter::{extract_predicate_for_keys, 
split_partition_and_data_predicates};
 use super::format_read_builder::FormatReadBuilder;
+use super::incremental_scan::{IncrementalScan, IncrementalScanMode};
 use super::partition_filter::PartitionFilter;
 use super::table_read::TableRead;
 use super::{Table, TableScan};
@@ -210,6 +211,32 @@ impl<'a> ReadBuilder<'a> {
         }
     }
 
+    /// Create a batch incremental scan over snapshot id range
+    /// `(start_exclusive, end_inclusive]`.
+    ///
+    /// Filters and projection configured on this builder are pushed into the
+    /// incremental plan (partition / bucket pruning on the delta path).
+    pub fn new_incremental_scan(
+        &self,
+        mode: IncrementalScanMode,
+        start_exclusive: i64,
+        end_inclusive: i64,
+    ) -> IncrementalScan<'a> {
+        match &self.0 {
+            ReadBuilderKind::Paimon(builder) => IncrementalScan::new(
+                builder.table,
+                builder.new_scan(),
+                mode,
+                start_exclusive,
+                end_inclusive,
+            ),
+            // Format tables share the API surface; planning fails with 
Unsupported.
+            ReadBuilderKind::Format(builder) => {
+                IncrementalScan::for_table(builder.table(), mode, 
start_exclusive, end_inclusive)
+            }
+        }
+    }
+
     /// Create a table read for consuming splits (e.g. from a scan plan).
     pub fn new_read(&self) -> Result<TableRead<'a>> {
         match &self.0 {
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index 0b8e550..f9e31ca 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -18,6 +18,7 @@
 use super::data_evolution_reader::DataEvolutionReader;
 use super::data_file_reader::DataFileReader;
 use super::format_table_read::FormatTableRead;
+use super::incremental_scan::{IncrementalPlan, IncrementalScanMode, 
IncrementalSplit};
 use super::kv_file_reader::{KeyValueFileReader, KeyValueReadConfig};
 use super::read_builder::split_scan_predicates;
 use super::{ArrowRecordBatchStream, Table};
@@ -107,6 +108,22 @@ impl<'a> TableRead<'a> {
             TableReadKind::Format(read) => read.to_arrow(data_splits),
         }
     }
+
+    /// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
+    ///
+    /// Only [`IncrementalSplit::Data`] is supported in this release. Diff
+    /// planning/read remains unimplemented.
+    pub fn to_incremental_arrow(
+        &self,
+        plan: &IncrementalPlan,
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        match &self.0 {
+            TableReadKind::Paimon(read) => read.to_incremental_arrow(plan),
+            TableReadKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "Format tables do not support incremental batch 
read".to_string(),
+            }),
+        }
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -160,6 +177,34 @@ impl<'a> PaimonTableRead<'a> {
         self
     }
 
+    /// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
+    pub fn to_incremental_arrow(
+        &self,
+        plan: &IncrementalPlan,
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        if plan.mode() == IncrementalScanMode::Diff {
+            return Err(crate::Error::Unsupported {
+                message: "Batch incremental Diff read not yet 
implemented".to_string(),
+            });
+        }
+
+        let mut data_splits = Vec::new();
+        for split in plan.splits() {
+            match split {
+                IncrementalSplit::Data(data) => data_splits.push(data.clone()),
+                IncrementalSplit::DiffPair { .. } => {
+                    return Err(crate::Error::UnexpectedError {
+                        message: "DiffPair appeared in non-Diff incremental 
plan".to_string(),
+                        source: None,
+                    });
+                }
+            }
+        }
+        // Delta / Changelog rows are read as-is from planned files (no 
full-table
+        // merge against historical base versions).
+        self.new_data_file_reader().read(&data_splits)
+    }
+
     /// Returns an [`ArrowRecordBatchStream`].
     pub fn to_arrow(&self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
         let has_primary_keys = !self.table.schema.primary_keys().is_empty();
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 58338ab..6653423 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -706,6 +706,16 @@ impl<'a> TableScan<'a> {
         }
     }
 
+    /// Plan data splits from a snapshot's delta manifest list only.
+    pub(crate) async fn plan_snapshot_delta(&self, snapshot: &Snapshot) -> 
crate::Result<Plan> {
+        match &self.0 {
+            TableScanKind::Paimon(scan) => 
scan.plan_snapshot_delta(snapshot).await,
+            TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "Format tables do not support incremental delta 
scan".to_string(),
+            }),
+        }
+    }
+
     #[cfg(test)]
     fn apply_limit_pushdown(&self, splits: Vec<DataSplit>) -> Vec<DataSplit> {
         match &self.0 {
@@ -1048,11 +1058,154 @@ impl<'a> PaimonTableScan<'a> {
         }
     }
 
+    /// Plan data splits from a snapshot's delta manifest list (APPEND deltas).
+    ///
+    /// Reuses the same split-building path as a full snapshot plan, but only
+    /// reads the delta manifest list and keeps ADD entries.
+    pub(crate) async fn plan_snapshot_delta(&self, snapshot: &Snapshot) -> 
crate::Result<Plan> {
+        self.ensure_query_auth_allowed()?;
+        let entries = self
+            .plan_manifest_list_entries(snapshot.delta_manifest_list())
+            .await?;
+        let data_evolution_read_field_ids = self.projected_read_field_ids()?;
+        self.plan_snapshot_from_entries(
+            snapshot.clone(),
+            entries,
+            data_evolution_read_field_ids.as_ref(),
+            None,
+        )
+        .await
+    }
+
+    /// Read entries from a single manifest list (delta or changelog) with
+    /// partition / bucket filter pushdown matching the full scan path.
+    async fn plan_manifest_list_entries(
+        &self,
+        manifest_list_name: &str,
+    ) -> crate::Result<Vec<ManifestEntry>> {
+        let file_io = self.table.file_io();
+        let table_path = self.table.location();
+        let core_options = CoreOptions::new(self.table.schema().options());
+        let has_primary_keys = !self.table.schema().primary_keys().is_empty();
+        let partition_fields = self.table.schema().partition_fields();
+
+        let mut manifest_metas =
+            read_manifest_list(file_io, table_path, manifest_list_name).await?;
+
+        if let Some(pf) = self.partition_filter.as_ref() {
+            if !partition_fields.is_empty() {
+                manifest_metas.retain(|meta| {
+                    let stats = meta.partition_stats();
+                    let min_values = 
BinaryRow::from_serialized_bytes(stats.min_values()).ok();
+                    let max_values = 
BinaryRow::from_serialized_bytes(stats.max_values()).ok();
+                    let null_counts = stats.null_counts().clone();
+                    let file_stats = FileStatsRows::for_manifest_partition(
+                        meta.num_added_files() + meta.num_deleted_files(),
+                        min_values,
+                        max_values,
+                        null_counts,
+                    );
+                    pf.matches_manifest(&file_stats, &partition_fields)
+                });
+            }
+        }
+
+        let bucket_key_fields: Vec<DataField> = if 
self.bucket_predicate.is_none() {
+            Vec::new()
+        } else {
+            let bucket_keys = core_options.bucket_key().unwrap_or_else(|| {
+                if has_primary_keys {
+                    self.table.schema().trimmed_primary_keys()
+                } else {
+                    Vec::new()
+                }
+            });
+            bucket_keys
+                .iter()
+                .filter_map(|key| {
+                    self.table
+                        .schema()
+                        .fields()
+                        .iter()
+                        .find(|f| f.name() == key)
+                        .cloned()
+                })
+                .collect::<Vec<_>>()
+        };
+        let bucket_function_type = core_options.bucket_function_type()?;
+
+        let base_path = format!("{}/{}", table_path.trim_end_matches('/'), 
MANIFEST_DIR);
+        let shared_cache = SharedSchemaCache::new();
+        let partition_filter = self.partition_filter.as_ref();
+        let bucket_predicate = self.bucket_predicate.as_ref();
+        let scan_all_files = self.scan_all_files;
+
+        let mut entries = Vec::new();
+        for meta in manifest_metas {
+            let path = format!("{base_path}/{}", meta.file_name());
+            let input = file_io.new_input(&path)?;
+            let bytes = input.read().await?;
+            let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> = 
HashMap::new();
+            let manifest_entries = 
crate::spec::avro::from_manifest_bytes_filtered_shared(
+                &bytes,
+                &shared_cache,
+                &mut |_kind, partition_bytes, bucket, total_buckets| {
+                    if has_primary_keys && !scan_all_files && bucket < 0 {
+                        return false;
+                    }
+                    if let Some(pred) = bucket_predicate {
+                        let targets = 
bucket_cache.entry(total_buckets).or_insert_with(|| {
+                            compute_target_buckets(
+                                pred,
+                                &bucket_key_fields,
+                                bucket_function_type,
+                                total_buckets,
+                            )
+                        });
+                        if let Some(targets) = targets {
+                            if !targets.contains(&bucket) {
+                                return false;
+                            }
+                        }
+                    }
+                    if let Some(pf) = partition_filter {
+                        match pf.matches_entry(partition_bytes) {
+                            Ok(false) => return false,
+                            Ok(true) => {}
+                            Err(_) => {}
+                        }
+                    }
+                    true
+                },
+            )?;
+            entries.extend(manifest_entries);
+        }
+        let entries = entries
+            .into_iter()
+            .filter(|entry| *entry.kind() == FileKind::Add)
+            .collect();
+        Ok(entries)
+    }
+
     async fn plan_snapshot(
         &self,
         snapshot: Snapshot,
         data_evolution_read_field_ids: Option<&HashSet<i32>>,
         mut trace: Option<&mut ScanTrace>,
+    ) -> crate::Result<Plan> {
+        let entries = self
+            .plan_manifest_entries_with_trace(&snapshot, trace.as_deref_mut())
+            .await?;
+        self.plan_snapshot_from_entries(snapshot, entries, 
data_evolution_read_field_ids, trace)
+            .await
+    }
+
+    async fn plan_snapshot_from_entries(
+        &self,
+        snapshot: Snapshot,
+        entries: Vec<ManifestEntry>,
+        data_evolution_read_field_ids: Option<&HashSet<i32>>,
+        mut trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Plan> {
         let file_io = self.table.file_io();
         let table_path = self.table.location();
@@ -1066,9 +1219,6 @@ impl<'a> PaimonTableScan<'a> {
         let open_file_cost = core_options.source_split_open_file_cost();
         let partition_keys = self.table.schema().partition_keys();
 
-        let entries = self
-            .plan_manifest_entries_with_trace(&snapshot, trace.as_deref_mut())
-            .await?;
         if entries.is_empty() {
             if let Some(trace) = trace {
                 trace.record_final_plan(0, 0, 0);
diff --git a/crates/paimon/tests/common/incremental_helpers.rs 
b/crates/paimon/tests/common/incremental_helpers.rs
new file mode 100644
index 0000000..e4c1abf
--- /dev/null
+++ b/crates/paimon/tests/common/incremental_helpers.rs
@@ -0,0 +1,142 @@
+// 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.
+
+//! Minimal helpers for batch incremental scan tests (no compact/lookup APIs).
+
+use arrow_array::{Int32Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as 
ArrowSchema};
+use paimon::catalog::Identifier;
+use paimon::io::FileIOBuilder;
+use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+use paimon::table::Table;
+use std::sync::Arc;
+
+pub async fn setup_dirs(file_io: &paimon::io::FileIO, table_path: &str) {
+    file_io
+        .mkdirs(&format!("{table_path}/schema"))
+        .await
+        .unwrap();
+    file_io
+        .mkdirs(&format!("{table_path}/snapshot"))
+        .await
+        .unwrap();
+}
+
+pub async fn persist_table_schema(
+    file_io: &paimon::io::FileIO,
+    table_path: &str,
+    schema: &TableSchema,
+) {
+    use bytes::Bytes;
+
+    let path = format!("{table_path}/schema/schema-{}", schema.id());
+    let json = serde_json::to_vec(schema).unwrap();
+    file_io
+        .new_output(&path)
+        .unwrap()
+        .write(Bytes::from(json))
+        .await
+        .unwrap();
+}
+
+/// Primary-key schema `(id, value)` with caller-supplied options.
+pub fn pk_schema(options: &[(&str, &str)]) -> TableSchema {
+    let mut builder = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .primary_key(["id"])
+        .option("bucket", "1");
+    for (k, v) in options {
+        builder = builder.option(*k, *v);
+    }
+    TableSchema::new(0, &builder.build().unwrap())
+}
+
+pub fn partitioned_pk_schema(bucket: &str) -> TableSchema {
+    let schema = Schema::builder()
+        .column("pt", DataType::VarChar(VarCharType::string_type()))
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .primary_key(["id"])
+        .partition_keys(["pt"])
+        .option("bucket", bucket)
+        .option("target-file-size", "1b")
+        .option("num-sorted-run.compaction-trigger", "2")
+        .build()
+        .unwrap();
+    TableSchema::new(0, &schema)
+}
+
+pub fn memory_table(path: &str, schema: TableSchema) -> (paimon::io::FileIO, 
Table) {
+    let file_io = FileIOBuilder::new("memory").build().unwrap();
+    let table = Table::new(
+        file_io.clone(),
+        Identifier::new("default", "incremental_test"),
+        path.to_string(),
+        schema,
+        None,
+    );
+    (file_io, table)
+}
+
+pub fn make_batch(ids: Vec<i32>, values: Vec<i32>) -> RecordBatch {
+    let schema = Arc::new(ArrowSchema::new(vec![
+        ArrowField::new("id", ArrowDataType::Int32, false),
+        ArrowField::new("value", ArrowDataType::Int32, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(Int32Array::from(ids)),
+            Arc::new(Int32Array::from(values)),
+        ],
+    )
+    .unwrap()
+}
+
+pub fn make_partitioned_batch(pts: Vec<&str>, ids: Vec<i32>, values: Vec<i32>) 
-> RecordBatch {
+    let schema = Arc::new(ArrowSchema::new(vec![
+        ArrowField::new("pt", ArrowDataType::Utf8, false),
+        ArrowField::new("id", ArrowDataType::Int32, false),
+        ArrowField::new("value", ArrowDataType::Int32, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(StringArray::from(pts)),
+            Arc::new(Int32Array::from(ids)),
+            Arc::new(Int32Array::from(values)),
+        ],
+    )
+    .unwrap()
+}
+
+pub async fn write_batch(table: &Table, batch: &RecordBatch) {
+    let builder = table.new_write_builder();
+    let mut w = builder.new_write().unwrap();
+    w.write_arrow_batch(batch).await.unwrap();
+    let msgs = w.prepare_commit().await.unwrap();
+    builder.new_commit().commit(msgs).await.unwrap();
+}
+
+pub async fn write_partitioned(table: &Table, batch: RecordBatch) {
+    let builder = table.new_write_builder();
+    let mut w = builder.new_write().unwrap();
+    w.write_arrow_batch(&batch).await.unwrap();
+    let msgs = w.prepare_commit().await.unwrap();
+    builder.new_commit().commit(msgs).await.unwrap();
+}
diff --git a/crates/paimon/tests/common/mod.rs 
b/crates/paimon/tests/common/mod.rs
new file mode 100644
index 0000000..a26f798
--- /dev/null
+++ b/crates/paimon/tests/common/mod.rs
@@ -0,0 +1,20 @@
+// 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.
+
+#![allow(dead_code)]
+
+pub mod incremental_helpers;
diff --git a/crates/paimon/tests/incremental_batch_scan_test.rs 
b/crates/paimon/tests/incremental_batch_scan_test.rs
new file mode 100644
index 0000000..26e9fcc
--- /dev/null
+++ b/crates/paimon/tests/incremental_batch_scan_test.rs
@@ -0,0 +1,267 @@
+// 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.
+
+mod common;
+
+use arrow_array::{Array, Int32Array, RecordBatch};
+use futures::TryStreamExt;
+use paimon::table::IncrementalScanMode;
+
+use common::incremental_helpers::{
+    make_batch, make_partitioned_batch, memory_table, partitioned_pk_schema, 
persist_table_schema,
+    pk_schema, setup_dirs, write_batch, write_partitioned,
+};
+
+fn collect_pairs(batches: &[RecordBatch]) -> Vec<(i32, i32)> {
+    let mut rows = Vec::new();
+    for batch in batches {
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let values = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        for row in 0..batch.num_rows() {
+            rows.push((ids.value(row), values.value(row)));
+        }
+    }
+    rows.sort_unstable();
+    rows
+}
+
+async fn read_incremental_pairs(
+    table: &paimon::table::Table,
+    mode: IncrementalScanMode,
+    start_exclusive: i64,
+    end_inclusive: i64,
+) -> Vec<(i32, i32)> {
+    let builder = table.new_read_builder();
+    let plan = builder
+        .new_incremental_scan(mode, start_exclusive, end_inclusive)
+        .plan()
+        .await
+        .unwrap();
+    let read = table.new_read_builder().new_read().unwrap();
+    let batches: Vec<RecordBatch> = read
+        .to_incremental_arrow(&plan)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    collect_pairs(&batches)
+}
+
+async fn plan_incremental(
+    table: &paimon::table::Table,
+    mode: IncrementalScanMode,
+    start_exclusive: i64,
+    end_inclusive: i64,
+) -> Result<paimon::table::IncrementalPlan, paimon::Error> {
+    table
+        .new_read_builder()
+        .new_incremental_scan(mode, start_exclusive, end_inclusive)
+        .plan()
+        .await
+}
+
+/// Start exclusive / end inclusive: (0, 2] includes both appends; (1, 2] only 
the second.
+#[tokio::test]
+async fn 
delta_between_snapshots_reads_only_append_snapshots_in_left_open_range() {
+    let table_path = "memory:/incremental_batch/delta_range";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    write_batch(&table, &make_batch(vec![2], vec![20])).await;
+
+    let rows = read_incremental_pairs(&table, IncrementalScanMode::Delta, 0, 
2).await;
+    assert_eq!(rows, vec![(1, 10), (2, 20)]);
+
+    let rows = read_incremental_pairs(&table, IncrementalScanMode::Delta, 1, 
2).await;
+    assert_eq!(rows, vec![(2, 20)]);
+}
+
+#[tokio::test]
+async fn auto_uses_delta_when_changelog_producer_is_none() {
+    let table_path = "memory:/incremental_batch/auto_delta";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![3], vec![30])).await;
+    write_batch(&table, &make_batch(vec![4], vec![40])).await;
+
+    let delta = read_incremental_pairs(&table, IncrementalScanMode::Delta, 0, 
2).await;
+    let auto = read_incremental_pairs(&table, IncrementalScanMode::Auto, 0, 
2).await;
+    assert_eq!(auto, delta);
+}
+
+/// Empty range (start == end) yields no splits / no rows.
+#[tokio::test]
+async fn delta_empty_range_returns_no_rows() {
+    let table_path = "memory:/incremental_batch/delta_empty";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+
+    let plan = plan_incremental(&table, IncrementalScanMode::Delta, 1, 1)
+        .await
+        .unwrap();
+    assert!(plan.splits().is_empty());
+    assert_eq!(plan.mode(), IncrementalScanMode::Delta);
+
+    let rows = read_incremental_pairs(&table, IncrementalScanMode::Delta, 1, 
1).await;
+    assert!(rows.is_empty());
+}
+
+/// Out-of-bounds ranges fail loudly with DataInvalid.
+#[tokio::test]
+async fn delta_rejects_out_of_range_snapshot_ids() {
+    let table_path = "memory:/incremental_batch/delta_oob";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+
+    // end past latest
+    let err = plan_incremental(&table, IncrementalScanMode::Delta, 0, 99)
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { .. }),
+        "expected DataInvalid for end > latest, got {err:?}"
+    );
+
+    // start below earliest - 1 (earliest=1, min_start=0)
+    let err = plan_incremental(&table, IncrementalScanMode::Delta, -2, 1)
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { .. }),
+        "expected DataInvalid for start < earliest-1, got {err:?}"
+    );
+
+    // start > end
+    let err = plan_incremental(&table, IncrementalScanMode::Delta, 2, 1)
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { .. }),
+        "expected DataInvalid for start > end, got {err:?}"
+    );
+}
+
+/// Partition filter from ReadBuilder is pushed into the delta plan path.
+#[tokio::test]
+async fn incremental_delta_scan_applies_partition_filter_from_read_builder() {
+    use paimon::spec::{Datum, PredicateBuilder};
+    use std::collections::HashMap;
+
+    let table_path = "memory:/incremental_batch/delta_partition_filter";
+    let (file_io, mut table) = memory_table(table_path, 
partitioned_pk_schema("1"));
+    table = table.copy_with_options(HashMap::from([(
+        "changelog-producer".to_string(),
+        "none".to_string(),
+    )]));
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_partitioned(&table, make_partitioned_batch(vec!["a"], vec![1], 
vec![10])).await;
+    write_partitioned(&table, make_partitioned_batch(vec!["b"], vec![2], 
vec![20])).await;
+
+    let filter = PredicateBuilder::new(table.schema().fields())
+        .equal("pt", Datum::String("a".to_string()))
+        .unwrap();
+    let mut builder = table.new_read_builder();
+    builder
+        .with_projection(&["id", "value"])
+        .unwrap()
+        .with_filter(filter);
+    let plan = builder
+        .new_incremental_scan(IncrementalScanMode::Delta, 0, 2)
+        .plan()
+        .await
+        .unwrap();
+    let read = builder.new_read().unwrap();
+    let batches: Vec<RecordBatch> = read
+        .to_incremental_arrow(&plan)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+
+    assert_eq!(collect_pairs(&batches), vec![(1, 10)]);
+}
+
+/// Changelog mode is reserved but not implemented in this PR.
+#[tokio::test]
+async fn changelog_mode_is_unsupported() {
+    let table_path = "memory:/incremental_batch/changelog_unsupported";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+
+    let err = plan_incremental(&table, IncrementalScanMode::Changelog, 0, 1)
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::Unsupported { .. }),
+        "expected Unsupported for Changelog, got {err:?}"
+    );
+}


Reply via email to