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 0fe37a75 feat: support batch incremental diff reads (#510)
0fe37a75 is described below

commit 0fe37a75f6a2148543a6022418adaeb2cfb02fdd
Author: Pandas <[email protected]>
AuthorDate: Thu Jul 30 09:08:31 2026 +0800

    feat: support batch incremental diff reads (#510)
---
 crates/paimon/src/spec/core_options.rs             |  28 +
 crates/paimon/src/table/audit_log_table.rs         |   3 +-
 crates/paimon/src/table/incremental_scan.rs        | 185 ++++-
 crates/paimon/src/table/kv_file_reader.rs          | 143 +++-
 crates/paimon/src/table/table_read.rs              | 891 ++++++++++++++++++++-
 crates/paimon/src/table/table_scan.rs              | 225 +++++-
 crates/paimon/tests/audit_log_table_test.rs        | 388 ++++++++-
 crates/paimon/tests/incremental_batch_scan_test.rs | 574 ++++++++++++-
 docs/mkdocs.yml                                    |   1 +
 docs/src/incremental-reading.md                    |  94 +++
 10 files changed, 2477 insertions(+), 55 deletions(-)

diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 1e814aec..507471c8 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -89,6 +89,8 @@ const IGNORE_DELETE_FALLBACK_KEYS: &[&str] = &[
     "deduplicate.ignore-delete",
     "partial-update.ignore-delete",
 ];
+const DIFF_PARALLELISM_OPTION: &str = "diff.parallelism";
+const DEFAULT_DIFF_PARALLELISM: usize = 4;
 const DEFAULT_COMMIT_MAX_RETRIES: u32 = 10;
 const DEFAULT_COMMIT_TIMEOUT_MS: u64 = 120_000;
 const DEFAULT_COMMIT_MIN_RETRY_WAIT_MS: u64 = 1_000;
@@ -512,6 +514,17 @@ impl<'a> CoreOptions<'a> {
             .is_some_and(|v| v.eq_ignore_ascii_case("true"))
     }
 
+    /// Parallelism for batch incremental Diff pair reads (`diff.parallelism`).
+    ///
+    /// Default is 4; values below 1 are clamped to 1.
+    pub fn diff_parallelism(&self) -> usize {
+        self.options
+            .get(DIFF_PARALLELISM_OPTION)
+            .and_then(|s| s.parse().ok())
+            .unwrap_or(DEFAULT_DIFF_PARALLELISM)
+            .max(1)
+    }
+
     pub fn data_evolution_enabled(&self) -> bool {
         self.options
             .get(DATA_EVOLUTION_ENABLED_OPTION)
@@ -1689,6 +1702,21 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_diff_parallelism_defaults() {
+        let options = HashMap::new();
+        let core = CoreOptions::new(&options);
+        assert_eq!(core.diff_parallelism(), 4);
+
+        let options = HashMap::from([(DIFF_PARALLELISM_OPTION.to_string(), 
"0".into())]);
+        let core = CoreOptions::new(&options);
+        assert_eq!(core.diff_parallelism(), 1);
+
+        let options = HashMap::from([(DIFF_PARALLELISM_OPTION.to_string(), 
"8".into())]);
+        let core = CoreOptions::new(&options);
+        assert_eq!(core.diff_parallelism(), 8);
+    }
+
     #[test]
     fn test_changelog_producer_accepts_known_values() {
         for (value, expected) in [
diff --git a/crates/paimon/src/table/audit_log_table.rs 
b/crates/paimon/src/table/audit_log_table.rs
index e5ce7572..a6b6e5fe 100644
--- a/crates/paimon/src/table/audit_log_table.rs
+++ b/crates/paimon/src/table/audit_log_table.rs
@@ -27,7 +27,7 @@ use crate::spec::{
 /// Incremental reads produce:
 /// - Delta: primary-key rows use physical `_VALUE_KIND`; append rows are `+I`
 /// - Changelog: kinds come from physical `_VALUE_KIND` (`+I`/`-U`/`+U`/`-D`)
-/// - Diff: not implemented in this release
+/// - Diff: before/after image comparison (`+I`/`-U`/`+U`/`-D`, equal keys 
skipped)
 #[derive(Debug, Clone)]
 pub struct AuditLogTable {
     wrapped: Table,
@@ -81,6 +81,7 @@ impl AuditLogTable {
     }
 
     pub fn to_arrow(&self, plan: &IncrementalPlan) -> 
crate::Result<ArrowRecordBatchStream> {
+        plan.validate()?;
         let read = self.wrapped.new_read_builder().new_read()?;
         read.to_audit_log_arrow(plan)
     }
diff --git a/crates/paimon/src/table/incremental_scan.rs 
b/crates/paimon/src/table/incremental_scan.rs
index 3aa169e9..cdfc15fa 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -35,10 +35,11 @@ pub enum IncrementalScanMode {
     /// Resolve to [`Delta`](Self::Delta) when `changelog-producer=none`,
     /// otherwise to [`Changelog`](Self::Changelog).
     Auto,
-    /// Diff before/after snapshots.
+    /// Diff before/after snapshot states for PK tables.
     ///
-    /// Not fully implemented in this release; planning returns
-    /// [`Error::Unsupported`](crate::Error::Unsupported).
+    /// Phase 1 supports only `merge-engine=deduplicate`. Planning compares the
+    /// full table state at `start_exclusive` vs `end_inclusive` and yields
+    /// per-(partition, bucket) [`IncrementalSplit::DiffPair`] units.
     Diff,
 }
 
@@ -46,7 +47,7 @@ pub enum IncrementalScanMode {
 #[derive(Debug, Clone)]
 pub enum IncrementalSplit {
     Data(DataSplit),
-    /// Per-(partition, bucket) diff pair. Memory bounded by one bucket's data.
+    /// Per-(partition, bucket) diff pair.
     DiffPair {
         before: Vec<DataSplit>,
         after: Vec<DataSplit>,
@@ -65,6 +66,80 @@ impl IncrementalPlan {
         Self { mode, splits }
     }
 
+    pub fn try_new(
+        mode: IncrementalScanMode,
+        splits: Vec<IncrementalSplit>,
+    ) -> crate::Result<Self> {
+        let plan = Self::new(mode, splits);
+        plan.validate()?;
+        Ok(plan)
+    }
+
+    /// Validate the plan at every point it crosses into a reader.
+    ///
+    /// `new` is retained for source compatibility, so callers can still build
+    /// an invalid plan. Readers must call this method instead of assuming a
+    /// plan came from the scanner.
+    pub fn validate(&self) -> crate::Result<()> {
+        if self.mode == IncrementalScanMode::Auto {
+            return Err(crate::Error::DataInvalid {
+                message: "Incremental plan mode Auto must be resolved before 
consumption"
+                    .to_string(),
+                source: None,
+            });
+        }
+        if self.mode == IncrementalScanMode::Diff {
+            let mut before_snapshot_id = None;
+            let mut after_snapshot_id = None;
+            for split in &self.splits {
+                let IncrementalSplit::DiffPair { before, after } = split else {
+                    return Err(crate::Error::DataInvalid {
+                        message: "Diff incremental plan contains a Data 
split".to_string(),
+                        source: None,
+                    });
+                };
+                validate_diff_pair(before, after)?;
+                if let Some(snapshot_id) = 
before.first().map(DataSplit::snapshot_id) {
+                    if before_snapshot_id.is_some_and(|expected| expected != 
snapshot_id) {
+                        return Err(crate::Error::DataInvalid {
+                            message: "Diff plan contains different before 
snapshots".to_string(),
+                            source: None,
+                        });
+                    }
+                    before_snapshot_id = Some(snapshot_id);
+                }
+                if let Some(snapshot_id) = 
after.first().map(DataSplit::snapshot_id) {
+                    if after_snapshot_id.is_some_and(|expected| expected != 
snapshot_id) {
+                        return Err(crate::Error::DataInvalid {
+                            message: "Diff plan contains different after 
snapshots".to_string(),
+                            source: None,
+                        });
+                    }
+                    after_snapshot_id = Some(snapshot_id);
+                }
+            }
+            if let (Some(before), Some(after)) = (before_snapshot_id, 
after_snapshot_id) {
+                if before >= after {
+                    return Err(crate::Error::DataInvalid {
+                        message: "Diff plan before snapshot must be earlier 
than after snapshot"
+                            .to_string(),
+                        source: None,
+                    });
+                }
+            }
+        } else if self
+            .splits
+            .iter()
+            .any(|split| matches!(split, IncrementalSplit::DiffPair { .. }))
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "Non-Diff incremental plan contains a 
DiffPair".to_string(),
+                source: None,
+            });
+        }
+        Ok(())
+    }
+
     /// Resolved mode (`Auto` already collapsed to `Delta` / `Changelog`).
     pub fn mode(&self) -> IncrementalScanMode {
         self.mode
@@ -85,6 +160,49 @@ impl IncrementalPlan {
     }
 }
 
+pub(crate) fn validate_diff_pair(before: &[DataSplit], after: &[DataSplit]) -> 
crate::Result<()> {
+    if before
+        .iter()
+        .chain(after)
+        .any(|split| split.row_ranges().is_some())
+    {
+        return Err(crate::Error::DataInvalid {
+            message: "Diff pair must not contain physical row 
ranges".to_string(),
+            source: None,
+        });
+    }
+    let first = before.first().or(after.first());
+    let Some(first) = first else {
+        return Ok(());
+    };
+    for side in [before, after] {
+        if let Some(first_in_side) = side.first() {
+            if side
+                .iter()
+                .any(|split| split.snapshot_id() != 
first_in_side.snapshot_id())
+            {
+                return Err(crate::Error::DataInvalid {
+                    message: "Diff pair side contains splits from different 
snapshots".to_string(),
+                    source: None,
+                });
+            }
+        }
+    }
+    for split in before.iter().chain(after) {
+        if split.partition() != first.partition()
+            || split.bucket() != first.bucket()
+            || split.bucket_path() != first.bucket_path()
+            || split.total_buckets() != first.total_buckets()
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "Diff pair contains splits from different partition 
buckets".to_string(),
+                source: None,
+            });
+        }
+    }
+    Ok(())
+}
+
 /// Batch incremental scan over a snapshot id range.
 pub struct IncrementalScan<'a> {
     table: &'a Table,
@@ -201,7 +319,7 @@ impl<'a> IncrementalScan<'a> {
             let plan = self.scan.plan_snapshot_delta(&snapshot).await?;
             
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
         }
-        Ok(IncrementalPlan::new(mode, splits))
+        IncrementalPlan::try_new(mode, splits)
     }
 
     async fn plan_changelog(&self, mode: IncrementalScanMode) -> 
crate::Result<IncrementalPlan> {
@@ -219,13 +337,60 @@ impl<'a> IncrementalScan<'a> {
             let plan = self.scan.plan_snapshot_changelog(&snapshot).await?;
             
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
         }
-        Ok(IncrementalPlan::new(mode, splits))
+        IncrementalPlan::try_new(mode, splits)
     }
 
     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(),
-        })
+        if self.table.schema().primary_keys().is_empty() {
+            return Err(crate::Error::Unsupported {
+                message: "Batch incremental Diff requires a table with primary 
keys".to_string(),
+            });
+        }
+        let core_options = CoreOptions::new(self.table.schema().options());
+        if core_options.merge_engine()? != 
crate::spec::MergeEngine::Deduplicate {
+            return Err(crate::Error::Unsupported {
+                message: "Batch incremental Diff only supports 
merge-engine=deduplicate in Phase 1"
+                    .to_string(),
+            });
+        }
+        let before = self
+            .snapshot_manager
+            .get_snapshot(self.start_exclusive)
+            .await?;
+        let after = self
+            .snapshot_manager
+            .get_snapshot(self.end_inclusive)
+            .await?;
+        let (before_plan, after_plan) = self.scan.plan_snapshot_diff(&before, 
&after).await?;
+
+        use std::collections::BTreeMap;
+        type PBKey = (Vec<u8>, i32);
+
+        let mut before_map: BTreeMap<PBKey, Vec<DataSplit>> = BTreeMap::new();
+        for split in before_plan.splits() {
+            let key = (split.partition().to_serialized_bytes(), 
split.bucket());
+            before_map.entry(key).or_default().push(split.clone());
+        }
+
+        let mut after_map: BTreeMap<PBKey, Vec<DataSplit>> = BTreeMap::new();
+        for split in after_plan.splits() {
+            let key = (split.partition().to_serialized_bytes(), 
split.bucket());
+            after_map.entry(key).or_default().push(split.clone());
+        }
+
+        let mut keys: std::collections::BTreeSet<PBKey> = 
before_map.keys().cloned().collect();
+        keys.extend(after_map.keys().cloned());
+
+        let mut splits = Vec::new();
+        for key in keys {
+            let before = before_map.remove(&key).unwrap_or_default();
+            let after = after_map.remove(&key).unwrap_or_default();
+            if before.is_empty() && after.is_empty() {
+                continue;
+            }
+            splits.push(IncrementalSplit::DiffPair { before, after });
+        }
+
+        IncrementalPlan::try_new(mode, splits)
     }
 }
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index ec221af5..64ffce73 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -73,6 +73,10 @@ pub(crate) struct KeyValueReadConfig {
     pub merge_engine: MergeEngine,
     pub sequence_fields: Vec<String>,
     pub read_batch_size: usize,
+    /// Merge files from all supplied splits into one globally key-sorted 
stream.
+    pub merge_splits: bool,
+    /// Optional cap on file streams opened by a single sort-merge group.
+    pub max_merge_file_streams: Option<usize>,
 }
 
 /// Keep only the conjuncts of `predicates` that reference primary-key columns,
@@ -146,6 +150,20 @@ fn widen_partial_update_sequence_group_fields(
     Ok(user_fields)
 }
 
+fn ensure_merge_fan_in_limit(stream_count: usize, limit: Option<usize>) -> 
crate::Result<()> {
+    if let Some(limit) = limit {
+        if stream_count <= limit {
+            return Ok(());
+        }
+        return Err(Error::Unsupported {
+            message: format!(
+                "KeyValueFileReader refuses to merge {stream_count} file 
streams in one sort-merge group; maximum is {limit}. Compact the table before 
reading this highly fragmented group"
+            ),
+        });
+    }
+    Ok(())
+}
+
 impl KeyValueFileReader {
     pub(crate) fn new(file_io: FileIO, config: KeyValueReadConfig) -> Self {
         let pushdown_predicates = retain_primary_key_conjuncts(
@@ -368,7 +386,15 @@ impl KeyValueFileReader {
             }
         }
 
-        let splits: Vec<DataSplit> = data_splits.to_vec();
+        let split_groups: Vec<Vec<DataSplit>> = if self.config.merge_splits {
+            vec![data_splits.to_vec()]
+        } else {
+            data_splits
+                .iter()
+                .cloned()
+                .map(|split| vec![split])
+                .collect()
+        };
         let file_io = self.file_io;
         let merge_engine = self.config.merge_engine;
         let schema_manager = self.config.schema_manager;
@@ -381,6 +407,7 @@ impl KeyValueFileReader {
         let primary_keys = self.config.primary_keys;
         let sequence_fields = self.config.sequence_fields;
         let read_batch_size = self.config.read_batch_size;
+        let max_merge_file_streams = self.config.max_merge_file_streams;
         #[cfg(test)]
         let input_batch_sizes = self.input_batch_sizes;
 
@@ -391,21 +418,31 @@ impl KeyValueFileReader {
         let merge_output_schema = 
build_target_arrow_schema(&merge_output_fields)?;
 
         Ok(try_stream! {
-            for split in &splits {
+            for split_group in &split_groups {
                 // DV mode should not reach KeyValueFileReader.
-                if split
-                    .data_deletion_files()
-                    .is_some_and(|files| files.iter().any(Option::is_some))
-                {
-                    Err(Error::Unsupported {
-                        message: "KeyValueFileReader does not support deletion 
vectors".to_string(),
-                    })?;
+                for split in split_group {
+                    if split
+                        .data_deletion_files()
+                        .is_some_and(|files| files.iter().any(Option::is_some))
+                    {
+                        Err(Error::Unsupported {
+                            message: "KeyValueFileReader does not support 
deletion vectors".to_string(),
+                        })?;
+                    }
                 }
-
+                let file_count = split_group
+                    .iter()
+                    .map(|split| split.data_files().len())
+                    .sum::<usize>();
+                if file_count == 0 {
+                    continue;
+                }
+                ensure_merge_fan_in_limit(file_count, max_merge_file_streams)?;
                 // Create one stream per data file.
                 let mut file_streams: Vec<ArrowRecordBatchStream> = Vec::new();
 
-                for file_meta in split.data_files().to_vec() {
+                for split in split_group {
+                    for file_meta in split.data_files().to_vec() {
                     let data_fields: Option<Vec<DataField>> = if 
file_meta.schema_id != table_schema_id {
                         let data_schema = 
schema_manager.schema(file_meta.schema_id).await?;
                         Some(data_schema.fields().to_vec())
@@ -428,7 +465,7 @@ impl KeyValueFileReader {
                         file_meta,
                         data_fields,
                         None,
-                        None,
+                        split.row_ranges().map(|ranges| ranges.to_vec()),
                     )?;
                     #[cfg(test)]
                     let stream = if let Some(batch_sizes) = 
input_batch_sizes.clone() {
@@ -443,6 +480,7 @@ impl KeyValueFileReader {
                         stream
                     };
                     file_streams.push(stream);
+                    }
                 }
 
                 if file_streams.is_empty() {
@@ -534,8 +572,10 @@ mod tests {
     use crate::catalog::Identifier;
     use crate::io::FileIOBuilder;
     use crate::spec::{
-        DataType, Datum, IntType, PredicateBuilder, Schema, TableSchema, 
VarCharType,
+        stats::BinaryTableStats, BinaryRow, DataFileMeta, DataType, Datum, 
IntType,
+        PredicateBuilder, Schema, TableSchema, VarCharType,
     };
+    use crate::table::source::DataSplitBuilder;
     use crate::table::table_commit::TableCommit;
     use crate::table::{Table, TableWrite};
     use arrow_array::{Array, Int32Array, StringArray};
@@ -684,6 +724,31 @@ mod tests {
             .collect()
     }
 
+    fn dummy_data_file(name: String) -> DataFileMeta {
+        DataFileMeta {
+            file_name: name,
+            file_size: 128,
+            row_count: 1,
+            min_key: Vec::new(),
+            max_key: Vec::new(),
+            key_stats: BinaryTableStats::new(Vec::new(), Vec::new(), 
Vec::new()),
+            value_stats: BinaryTableStats::new(Vec::new(), Vec::new(), 
Vec::new()),
+            min_sequence_number: 0,
+            max_sequence_number: 0,
+            schema_id: 0,
+            level: 0,
+            extra_files: Vec::new(),
+            creation_time: None,
+            delete_row_count: Some(0),
+            embedded_index: None,
+            file_source: None,
+            value_stats_cols: None,
+            external_path: None,
+            first_row_id: None,
+            write_cols: None,
+        }
+    }
+
     #[test]
     fn retain_primary_key_conjuncts_semantics() {
         let fields = vec![
@@ -763,6 +828,56 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn kv_merge_rejects_too_many_file_streams_on_read_path() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_merge_fan_in_limit";
+        let table = pk_table(&file_io, table_path, &[]);
+        let core_options = table.schema().core_options();
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(format!("{table_path}/bucket-0"))
+            .with_total_buckets(1)
+            .with_data_files(
+                (0..257)
+                    .map(|i| dummy_data_file(format!("file-{i}.parquet")))
+                    .collect(),
+            )
+            .build()
+            .unwrap();
+        let reader = KeyValueFileReader::new(
+            table.file_io().clone(),
+            KeyValueReadConfig {
+                table_name: table.identifier().full_name(),
+                table_options: table.schema().options().clone(),
+                schema_manager: table.schema_manager().clone(),
+                table_schema_id: table.schema().id(),
+                table_fields: table.schema().fields().to_vec(),
+                read_type: table.schema().fields().to_vec(),
+                predicates: Vec::new(),
+                primary_keys: table.schema().trimmed_primary_keys(),
+                merge_engine: core_options.merge_engine().unwrap(),
+                sequence_fields: Vec::new(),
+                read_batch_size: core_options.read_batch_size().unwrap(),
+                merge_splits: true,
+                max_merge_file_streams: Some(256),
+            },
+        );
+
+        let err = reader
+            .read(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap_err();
+        assert!(
+            matches!(err, Error::Unsupported { message } if 
message.contains("file streams")),
+            "KV merge must fail before opening an unbounded number of file 
streams"
+        );
+    }
+
     #[tokio::test]
     async fn 
kv_input_decode_honors_read_batch_size_without_changing_merge_batching() {
         let file_io = test_file_io();
@@ -809,6 +924,8 @@ mod tests {
                     .map(|field| field.to_string())
                     .collect(),
                 read_batch_size: core_options.read_batch_size().unwrap(),
+                merge_splits: false,
+                max_merge_file_streams: None,
             },
         )
         .with_input_batch_sizes(input_batch_sizes.clone());
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index 6c57f6b6..0a179b49 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -29,9 +29,15 @@ use crate::spec::{
     VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
 };
 use crate::DataSplit;
-use arrow_array::{Array, ArrayRef, RecordBatch, StringArray};
+use arrow_array::{
+    builder::StringBuilder, Array, ArrayRef, RecordBatch, RecordBatchOptions, 
StringArray,
+    UInt32Array,
+};
 use arrow_schema::Schema as ArrowSchema;
-use futures::StreamExt;
+use arrow_select::concat::concat as arrow_concat;
+use arrow_select::take::take;
+use futures::{stream, StreamExt};
+use std::cmp::Ordering;
 use std::sync::Arc;
 
 /// Table read: reads data from splits (e.g. produced by [TableScan::plan]).
@@ -136,12 +142,14 @@ impl<'a> TableRead<'a> {
 
     /// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
     ///
-    /// Only [`IncrementalSplit::Data`] is supported in this release. Diff
-    /// planning/read remains unimplemented.
+    /// Delta/Changelog use [`IncrementalSplit::Data`]. Diff uses
+    /// [`IncrementalSplit::DiffPair`] and emits after-image rows only.
     pub fn to_incremental_arrow(
         &self,
         plan: &IncrementalPlan,
     ) -> crate::Result<ArrowRecordBatchStream> {
+        self.ensure_query_auth_allowed()?;
+        plan.validate()?;
         match &self.0 {
             TableReadKind::Paimon(read) => read.to_incremental_arrow(plan),
             TableReadKind::Format(_) => Err(crate::Error::Unsupported {
@@ -154,12 +162,14 @@ impl<'a> TableRead<'a> {
     ///
     /// Output schema is `rowkind` (+ optional `_SEQUENCE_NUMBER`) followed by
     /// the projected user columns. Primary-key Delta and Changelog rows take
-    /// kinds from `_VALUE_KIND`; append-only Delta rows are `+I`. Diff remains
-    /// unsupported.
+    /// kinds from `_VALUE_KIND`; append-only Delta rows are `+I`. Diff emits
+    /// `+I`/`-U`/`+U`/`-D` from before/after image comparison.
     pub fn to_audit_log_arrow(
         &self,
         plan: &IncrementalPlan,
     ) -> crate::Result<ArrowRecordBatchStream> {
+        self.ensure_query_auth_allowed()?;
+        plan.validate()?;
         match &self.0 {
             TableReadKind::Paimon(read) => read.to_audit_log_arrow(plan),
             TableReadKind::Format(_) => Err(crate::Error::Unsupported {
@@ -167,6 +177,10 @@ impl<'a> TableRead<'a> {
             }),
         }
     }
+
+    fn ensure_query_auth_allowed(&self) -> crate::Result<()> {
+        
CoreOptions::new(self.table().schema().options()).ensure_read_authorized()
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -233,9 +247,7 @@ impl<'a> PaimonTableRead<'a> {
         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(),
-            });
+            return self.to_incremental_diff_arrow(plan);
         }
 
         let mut data_splits = Vec::new();
@@ -255,20 +267,54 @@ impl<'a> PaimonTableRead<'a> {
         self.new_data_file_reader()?.read(&data_splits)
     }
 
+    fn to_incremental_diff_arrow(
+        &self,
+        plan: &IncrementalPlan,
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        let pairs = diff_pairs(plan)?;
+        let parallel = 
CoreOptions::new(self.table.schema().options()).diff_parallelism();
+        let table = self.table.clone();
+        let read_type = self.read_type.clone();
+        let data_predicates = self.data_predicates.clone();
+
+        Ok(Box::pin(async_stream::try_stream! {
+            let mut workers = stream::iter(pairs.into_iter().map(|(before, 
after)| {
+                let table = table.clone();
+                let read_type = read_type.clone();
+                let data_predicates = data_predicates.clone();
+                let worker: ArrowRecordBatchStream = 
Box::pin(async_stream::try_stream! {
+                    let pair_read =
+                        PaimonTableRead::new(&table, read_type, 
data_predicates);
+                    let mut pair_stream = 
pair_read.to_diff_after_image_stream(&before, &after)?;
+                    while let Some(batch) = pair_stream.next().await {
+                        yield batch?;
+                    }
+                });
+                worker
+            }))
+            .flatten_unordered(parallel);
+            while let Some(batch) = workers.next().await {
+                yield batch?;
+            }
+        }))
+    }
+
     /// Returns an audit-log stream for a planned incremental scan.
     pub fn to_audit_log_arrow(
         &self,
         plan: &IncrementalPlan,
     ) -> crate::Result<ArrowRecordBatchStream> {
         match plan.mode() {
-            IncrementalScanMode::Diff => Err(crate::Error::Unsupported {
-                message: "Batch incremental Diff audit read not yet 
implemented".to_string(),
-            }),
+            IncrementalScanMode::Diff => self.audit_diff_stream(plan),
             IncrementalScanMode::Delta => {
                 self.audit_raw_stream(plan, 
!self.table.schema().primary_keys().is_empty())
             }
             IncrementalScanMode::Changelog => self.audit_raw_stream(plan, 
true),
-            IncrementalScanMode::Auto => unreachable!("Auto resolved during 
plan()"),
+            IncrementalScanMode::Auto => Err(crate::Error::DataInvalid {
+                message: "Incremental plan mode Auto must be resolved before 
consumption"
+                    .to_string(),
+                source: None,
+            }),
         }
     }
 
@@ -277,6 +323,7 @@ impl<'a> PaimonTableRead<'a> {
         plan: &IncrementalPlan,
         has_value_kind: bool,
     ) -> crate::Result<ArrowRecordBatchStream> {
+        plan.validate()?;
         let data_splits = plan.data_splits();
         let user_read_type = self.read_type.clone();
         let include_sequence = audit_sequence_number_enabled(self.table);
@@ -361,6 +408,240 @@ impl<'a> PaimonTableRead<'a> {
         }))
     }
 
+    fn audit_diff_stream(&self, plan: &IncrementalPlan) -> 
crate::Result<ArrowRecordBatchStream> {
+        let pairs = diff_pairs(plan)?;
+        let parallel = 
CoreOptions::new(self.table.schema().options()).diff_parallelism();
+        let table = self.table.clone();
+        let read_type = self.read_type.clone();
+        let data_predicates = self.data_predicates.clone();
+
+        Ok(Box::pin(async_stream::try_stream! {
+            let mut workers = stream::iter(pairs.into_iter().map(|(before, 
after)| {
+                let table = table.clone();
+                let read_type = read_type.clone();
+                let data_predicates = data_predicates.clone();
+                let worker: ArrowRecordBatchStream = 
Box::pin(async_stream::try_stream! {
+                    let pair_read = PaimonTableRead::new(&table, read_type, 
data_predicates);
+                    let mut pair_stream =
+                        pair_read.to_audit_log_arrow_for_diff(&before, 
&after)?;
+                    while let Some(batch) = pair_stream.next().await {
+                        yield batch?;
+                    }
+                });
+                worker
+            }))
+            .flatten_unordered(parallel);
+            while let Some(batch) = workers.next().await {
+                yield batch?;
+            }
+        }))
+    }
+
+    fn to_audit_log_arrow_for_diff(
+        &self,
+        before: &[DataSplit],
+        after: &[DataSplit],
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        let include_sequence = audit_sequence_number_enabled(self.table);
+        let audit_schema = audit_schema_for_read_type(&self.read_type, 
include_sequence)?;
+
+        let mut diff_read_type = self.table.schema().fields().to_vec();
+        ensure_diff_supported_read_type(&diff_read_type)?;
+        if include_sequence {
+            diff_read_type.insert(
+                0,
+                DataField::new(
+                    SEQUENCE_NUMBER_FIELD_ID,
+                    SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+                    DataType::BigInt(BigIntType::new()),
+                ),
+            );
+        }
+
+        let key_indices = primary_key_indices(self.table, &diff_read_type)?;
+        let value_indices = value_indices_for_diff(self.table, 
&diff_read_type);
+
+        let before = before.to_vec();
+        let after = after.to_vec();
+        let table = self.table.clone();
+        let read_type_for_output = self.read_type.clone();
+        let data_predicates = self.data_predicates.clone();
+
+        Ok(Box::pin(async_stream::try_stream! {
+            let core_options = CoreOptions::new(table.schema().options());
+            let pair_read = PaimonTableRead::new(&table, 
diff_read_type.clone(), data_predicates);
+            let before_stream =
+                pair_read.read_pk_sorted_for_diff_with_type(&before, 
&core_options, &diff_read_type)?;
+            let after_stream =
+                pair_read.read_pk_sorted_for_diff_with_type(&after, 
&core_options, &diff_read_type)?;
+            let mut bc = ArrowCursor::new(before_stream).await?;
+            let mut ac = ArrowCursor::new(after_stream).await?;
+            let mut data_col_indices: Option<Vec<usize>> = None;
+            let mut builder = AuditBatchBuilder::new(audit_schema.clone());
+
+            while bc.alive() || ac.alive() {
+                let indices = data_col_indices.get_or_insert_with(|| {
+                    let sample = if bc.alive() {
+                        bc.batch()
+                    } else {
+                        ac.batch()
+                    };
+                    diff_output_col_indices(sample, &read_type_for_output, 
include_sequence)
+                        .expect("diff output column indices")
+                });
+                if !builder.has_data_columns() {
+                    builder.set_data_col_indices(indices.clone());
+                }
+                match cursor_cmp(&bc, &ac, &key_indices, &value_indices)? {
+                    CursorOrd::BeforeOnly => {
+                        builder.push("-D", bc.batch(), bc.row());
+                        bc.advance().await?;
+                    }
+                    CursorOrd::AfterOnly => {
+                        builder.push("+I", ac.batch(), ac.row());
+                        ac.advance().await?;
+                    }
+                    CursorOrd::EqualSame => {
+                        bc.advance().await?;
+                        ac.advance().await?;
+                    }
+                    CursorOrd::EqualDiff => {
+                        builder.push("-U", bc.batch(), bc.row());
+                        builder.push("+U", ac.batch(), ac.row());
+                        bc.advance().await?;
+                        ac.advance().await?;
+                    }
+                }
+                if builder.len() >= DIFF_BATCH_SIZE {
+                    yield builder.flush()?;
+                }
+            }
+            if builder.len() > 0 {
+                yield builder.flush()?;
+            }
+        }))
+    }
+
+    fn to_diff_after_image_stream(
+        &self,
+        before: &[DataSplit],
+        after: &[DataSplit],
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        let diff_read_type = self.table.schema().fields().to_vec();
+        ensure_diff_supported_read_type(&diff_read_type)?;
+        let key_indices = primary_key_indices(self.table, &diff_read_type)?;
+        let value_indices = value_indices_for_diff(self.table, 
&diff_read_type);
+        let output_schema = build_target_arrow_schema(&self.read_type)?;
+        let output_col_indices = self
+            .read_type
+            .iter()
+            .map(|field| {
+                diff_read_type
+                    .iter()
+                    .position(|candidate| candidate.id() == field.id())
+                    .ok_or_else(|| crate::Error::DataInvalid {
+                        message: format!("Diff read missing projected column 
'{}'", field.name()),
+                        source: None,
+                    })
+            })
+            .collect::<crate::Result<Vec<_>>>()?;
+
+        let table = self.table.clone();
+        let data_predicates = self.data_predicates.clone();
+        let before = before.to_vec();
+        let after = after.to_vec();
+
+        Ok(Box::pin(async_stream::try_stream! {
+            let core_options = CoreOptions::new(table.schema().options());
+            let pair_read = PaimonTableRead::new(&table, 
diff_read_type.clone(), data_predicates);
+            let before_stream = pair_read.read_pk_sorted_for_diff_with_type(
+                &before,
+                &core_options,
+                &diff_read_type,
+            )?;
+            let after_stream = pair_read.read_pk_sorted_for_diff_with_type(
+                &after,
+                &core_options,
+                &diff_read_type,
+            )?;
+            let mut bc = ArrowCursor::new(before_stream).await?;
+            let mut ac = ArrowCursor::new(after_stream).await?;
+            let mut builder =
+                DiffAfterImageBatchBuilder::new(output_schema.clone(), 
output_col_indices.clone());
+
+            while bc.alive() || ac.alive() {
+                match cursor_cmp(&bc, &ac, &key_indices, &value_indices)? {
+                    CursorOrd::BeforeOnly => {
+                        bc.advance().await?;
+                    }
+                    CursorOrd::AfterOnly => {
+                        builder.push(ac.batch(), ac.row());
+                        ac.advance().await?;
+                    }
+                    CursorOrd::EqualSame => {
+                        bc.advance().await?;
+                        ac.advance().await?;
+                    }
+                    CursorOrd::EqualDiff => {
+                        builder.push(ac.batch(), ac.row());
+                        bc.advance().await?;
+                        ac.advance().await?;
+                    }
+                }
+                if builder.len() >= DIFF_BATCH_SIZE {
+                    yield builder.flush()?;
+                }
+            }
+            if builder.len() > 0 {
+                yield builder.flush()?;
+            }
+        }))
+    }
+
+    fn read_pk_sorted_for_diff_with_type(
+        &self,
+        splits: &[DataSplit],
+        core_options: &CoreOptions,
+        read_type: &[DataField],
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        if splits.is_empty() {
+            return Ok(Box::pin(futures::stream::empty()));
+        }
+        for split in splits {
+            if split
+                .data_deletion_files()
+                .is_some_and(|files| files.iter().any(|file| file.is_some()))
+            {
+                return Err(crate::Error::Unsupported {
+                    message: "Batch incremental Diff does not support deletion 
vectors".to_string(),
+                });
+            }
+        }
+        let reader = KeyValueFileReader::new(
+            self.table.file_io.clone(),
+            KeyValueReadConfig {
+                table_name: self.table.identifier().full_name(),
+                table_options: self.table.schema().options().clone(),
+                schema_manager: self.table.schema_manager().clone(),
+                table_schema_id: self.table.schema().id(),
+                table_fields: self.table.schema.fields().to_vec(),
+                read_type: read_type.to_vec(),
+                predicates: self.data_predicates.clone(),
+                primary_keys: self.table.schema.trimmed_primary_keys(),
+                merge_engine: core_options.merge_engine()?,
+                sequence_fields: core_options
+                    .sequence_fields()
+                    .iter()
+                    .map(|s| s.to_string())
+                    .collect(),
+                read_batch_size: core_options.read_batch_size()?,
+                merge_splits: true,
+                max_merge_file_streams: Some(256),
+            },
+        );
+        reader.read(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();
@@ -490,6 +771,8 @@ impl<'a> PaimonTableRead<'a> {
                     .map(|s| s.to_string())
                     .collect(),
                 read_batch_size: core_options.read_batch_size()?,
+                merge_splits: false,
+                max_merge_file_streams: None,
             },
         );
         reader.read(splits)
@@ -609,6 +892,491 @@ fn rowkind_array_from_column(column: &dyn 
arrow_array::Array) -> crate::Result<S
     Ok(StringArray::from(strings))
 }
 
+const DIFF_BATCH_SIZE: usize = 8192;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CursorOrd {
+    BeforeOnly,
+    AfterOnly,
+    EqualSame,
+    EqualDiff,
+}
+
+struct ArrowCursor {
+    stream: ArrowRecordBatchStream,
+    batch: Option<RecordBatch>,
+    row: usize,
+}
+
+impl ArrowCursor {
+    async fn new(stream: ArrowRecordBatchStream) -> crate::Result<Self> {
+        let mut cursor = Self {
+            stream,
+            batch: None,
+            row: 0,
+        };
+        cursor.advance().await?;
+        Ok(cursor)
+    }
+
+    fn alive(&self) -> bool {
+        self.batch.is_some()
+    }
+
+    fn batch(&self) -> &RecordBatch {
+        self.batch.as_ref().expect("cursor must be alive")
+    }
+
+    fn row(&self) -> usize {
+        self.row
+    }
+
+    async fn advance(&mut self) -> crate::Result<()> {
+        loop {
+            if let Some(ref batch) = self.batch {
+                if self.row + 1 < batch.num_rows() {
+                    self.row += 1;
+                    return Ok(());
+                }
+            }
+            match self.stream.next().await {
+                Some(Ok(batch)) if batch.num_rows() > 0 => {
+                    self.batch = Some(batch);
+                    self.row = 0;
+                    return Ok(());
+                }
+                Some(Ok(_)) => continue,
+                Some(Err(e)) => return Err(e),
+                None => {
+                    self.batch = None;
+                    return Ok(());
+                }
+            }
+        }
+    }
+}
+
+struct AuditBatchBuilder {
+    schema: Arc<ArrowSchema>,
+    rowkind: StringBuilder,
+    row_indices: Vec<(usize, usize)>,
+    pinned_batches: Vec<RecordBatch>,
+    data_col_indices: Vec<usize>,
+    len: usize,
+}
+
+impl AuditBatchBuilder {
+    fn new(schema: Arc<ArrowSchema>) -> Self {
+        Self {
+            schema,
+            rowkind: StringBuilder::new(),
+            row_indices: Vec::new(),
+            pinned_batches: Vec::new(),
+            data_col_indices: Vec::new(),
+            len: 0,
+        }
+    }
+
+    fn has_data_columns(&self) -> bool {
+        !self.data_col_indices.is_empty()
+    }
+
+    fn set_data_col_indices(&mut self, indices: Vec<usize>) {
+        self.data_col_indices = indices;
+    }
+
+    fn len(&self) -> usize {
+        self.len
+    }
+
+    fn push(&mut self, kind: &str, batch: &RecordBatch, row: usize) {
+        self.rowkind.append_value(kind);
+        let batch_id = self.pin_batch(batch);
+        self.row_indices.push((batch_id, row));
+        self.len += 1;
+    }
+
+    fn pin_batch(&mut self, batch: &RecordBatch) -> usize {
+        if let Some(last) = self.pinned_batches.last() {
+            if std::ptr::eq(batch, last) {
+                return self.pinned_batches.len() - 1;
+            }
+        }
+        let batch_id = self.pinned_batches.len();
+        self.pinned_batches.push(batch.clone());
+        batch_id
+    }
+
+    fn flush(&mut self) -> crate::Result<RecordBatch> {
+        let mut columns: Vec<ArrayRef> = vec![Arc::new(self.rowkind.finish())];
+        self.rowkind = StringBuilder::new();
+        for &col_idx in &self.data_col_indices {
+            let taken: Vec<ArrayRef> = self
+                .row_indices
+                .iter()
+                .map(|(batch_id, row)| {
+                    take(
+                        
self.pinned_batches[*batch_id].column(col_idx).as_ref(),
+                        &UInt32Array::from(vec![*row as u32]),
+                        None,
+                    )
+                    .map_err(|e| crate::Error::UnexpectedError {
+                        message: format!("Failed to take audit diff column: 
{e}"),
+                        source: Some(Box::new(e)),
+                    })
+                })
+                .collect::<crate::Result<Vec<_>>>()?;
+            let refs: Vec<&dyn Array> = taken.iter().map(|array| 
array.as_ref()).collect();
+            columns.push(
+                arrow_concat(&refs).map_err(|e| crate::Error::UnexpectedError {
+                    message: format!("Failed to concat audit diff column: 
{e}"),
+                    source: Some(Box::new(e)),
+                })?,
+            );
+        }
+        self.row_indices.clear();
+        self.pinned_batches.clear();
+        self.len = 0;
+        RecordBatch::try_new(self.schema.clone(), columns).map_err(|e| {
+            crate::Error::UnexpectedError {
+                message: format!("Failed to build audit diff batch: {e}"),
+                source: Some(Box::new(e)),
+            }
+        })
+    }
+}
+
+struct DiffAfterImageBatchBuilder {
+    schema: Arc<ArrowSchema>,
+    row_indices: Vec<(usize, usize)>,
+    pinned_batches: Vec<RecordBatch>,
+    col_indices: Vec<usize>,
+    len: usize,
+}
+
+impl DiffAfterImageBatchBuilder {
+    fn new(schema: Arc<ArrowSchema>, col_indices: Vec<usize>) -> Self {
+        Self {
+            schema,
+            row_indices: Vec::new(),
+            pinned_batches: Vec::new(),
+            col_indices,
+            len: 0,
+        }
+    }
+
+    fn len(&self) -> usize {
+        self.len
+    }
+
+    fn push(&mut self, batch: &RecordBatch, row: usize) {
+        let batch_id = self.pin_batch(batch);
+        self.row_indices.push((batch_id, row));
+        self.len += 1;
+    }
+
+    fn pin_batch(&mut self, batch: &RecordBatch) -> usize {
+        if let Some(last) = self.pinned_batches.last() {
+            if std::ptr::eq(batch, last) {
+                return self.pinned_batches.len() - 1;
+            }
+        }
+        let batch_id = self.pinned_batches.len();
+        self.pinned_batches.push(batch.clone());
+        batch_id
+    }
+
+    fn flush(&mut self) -> crate::Result<RecordBatch> {
+        let row_count = self.len;
+        let mut columns = Vec::with_capacity(self.col_indices.len());
+        for &col_idx in &self.col_indices {
+            let taken: Vec<ArrayRef> = self
+                .row_indices
+                .iter()
+                .map(|(batch_id, row)| {
+                    take(
+                        
self.pinned_batches[*batch_id].column(col_idx).as_ref(),
+                        &UInt32Array::from(vec![*row as u32]),
+                        None,
+                    )
+                    .map_err(|e| crate::Error::UnexpectedError {
+                        message: format!("Failed to take diff after-image 
column: {e}"),
+                        source: Some(Box::new(e)),
+                    })
+                })
+                .collect::<crate::Result<Vec<_>>>()?;
+            let refs: Vec<&dyn Array> = taken.iter().map(|array| 
array.as_ref()).collect();
+            columns.push(
+                arrow_concat(&refs).map_err(|e| crate::Error::UnexpectedError {
+                    message: format!("Failed to concat diff after-image 
column: {e}"),
+                    source: Some(Box::new(e)),
+                })?,
+            );
+        }
+        self.row_indices.clear();
+        self.pinned_batches.clear();
+        self.len = 0;
+        let options = 
RecordBatchOptions::new().with_row_count(Some(row_count));
+        RecordBatch::try_new_with_options(self.schema.clone(), columns, 
&options).map_err(|e| {
+            crate::Error::UnexpectedError {
+                message: format!("Failed to build diff after-image batch: 
{e}"),
+                source: Some(Box::new(e)),
+            }
+        })
+    }
+}
+
+fn diff_pairs(plan: &IncrementalPlan) -> crate::Result<Vec<(Vec<DataSplit>, 
Vec<DataSplit>)>> {
+    plan.validate()?;
+    if plan.mode() != IncrementalScanMode::Diff {
+        return Err(crate::Error::DataInvalid {
+            message: "Diff reader requires a Diff incremental 
plan".to_string(),
+            source: None,
+        });
+    }
+    plan.splits()
+        .iter()
+        .map(|split| match split {
+            IncrementalSplit::DiffPair { before, after } => 
Ok((before.clone(), after.clone())),
+            IncrementalSplit::Data(_) => Err(crate::Error::DataInvalid {
+                message: "Diff incremental plan contains a Data 
split".to_string(),
+                source: None,
+            }),
+        })
+        .collect()
+}
+
+fn diff_output_col_indices(
+    batch: &RecordBatch,
+    read_type: &[DataField],
+    include_sequence: bool,
+) -> crate::Result<Vec<usize>> {
+    let mut indices = Vec::with_capacity(read_type.len() + 
usize::from(include_sequence));
+    if include_sequence {
+        indices.push(
+            batch
+                .schema()
+                .index_of(SEQUENCE_NUMBER_FIELD_NAME)
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!("Diff read missing _SEQUENCE_NUMBER: 
{e}"),
+                    source: None,
+                })?,
+        );
+    }
+    for field in read_type {
+        indices.push(batch.schema().index_of(field.name()).map_err(|e| {
+            crate::Error::DataInvalid {
+                message: format!("Diff read missing column '{}': {e}", 
field.name()),
+                source: None,
+            }
+        })?);
+    }
+    Ok(indices)
+}
+
+fn value_indices_for_diff(table: &Table, fields: &[DataField]) -> Vec<usize> {
+    let primary_key_names = table.schema().trimmed_primary_keys();
+    let primary_keys: std::collections::HashSet<&str> =
+        primary_key_names.iter().map(|key| key.as_str()).collect();
+    fields
+        .iter()
+        .enumerate()
+        .filter(|(_, field)| {
+            field.name() != SEQUENCE_NUMBER_FIELD_NAME && 
!primary_keys.contains(field.name())
+        })
+        .map(|(index, _)| index)
+        .collect()
+}
+
+fn primary_key_indices(table: &Table, read_type: &[DataField]) -> 
crate::Result<Vec<usize>> {
+    let mut indices = Vec::new();
+    for pk in table.schema().trimmed_primary_keys() {
+        let idx = read_type
+            .iter()
+            .position(|field| field.name() == pk)
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: format!("Primary key column '{pk}' missing from Diff 
comparison schema"),
+                source: None,
+            })?;
+        indices.push(idx);
+    }
+    Ok(indices)
+}
+
+fn ensure_diff_supported_read_type(read_type: &[DataField]) -> 
crate::Result<()> {
+    for field in read_type {
+        if !is_diff_supported_type(field.data_type()) {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Batch incremental Diff does not support column '{}' of 
type {:?}",
+                    field.name(),
+                    field.data_type()
+                ),
+            });
+        }
+    }
+    Ok(())
+}
+
+fn is_diff_supported_type(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::Boolean(_)
+            | DataType::TinyInt(_)
+            | DataType::SmallInt(_)
+            | DataType::Int(_)
+            | DataType::BigInt(_)
+            | DataType::Float(_)
+            | DataType::Double(_)
+            | DataType::Char(_)
+            | DataType::VarChar(_)
+            | DataType::Date(_)
+    )
+}
+
+fn cursor_cmp(
+    bc: &ArrowCursor,
+    ac: &ArrowCursor,
+    key_indices: &[usize],
+    value_indices: &[usize],
+) -> crate::Result<CursorOrd> {
+    match (bc.alive(), ac.alive()) {
+        (false, false) => unreachable!("cursor_cmp called with both streams 
exhausted"),
+        (false, true) => return Ok(CursorOrd::AfterOnly),
+        (true, false) => return Ok(CursorOrd::BeforeOnly),
+        (true, true) => {}
+    }
+    match compare_pk(bc, ac, key_indices)? {
+        Ordering::Less => Ok(CursorOrd::BeforeOnly),
+        Ordering::Greater => Ok(CursorOrd::AfterOnly),
+        Ordering::Equal => {
+            if rows_equal_at(bc.batch(), bc.row(), ac.batch(), ac.row(), 
value_indices)? {
+                Ok(CursorOrd::EqualSame)
+            } else {
+                Ok(CursorOrd::EqualDiff)
+            }
+        }
+    }
+}
+
+fn compare_pk(
+    bc: &ArrowCursor,
+    ac: &ArrowCursor,
+    key_indices: &[usize],
+) -> crate::Result<Ordering> {
+    for &idx in key_indices {
+        let ord = scalar_compare(
+            bc.batch().column(idx),
+            bc.row(),
+            ac.batch().column(idx),
+            ac.row(),
+        )?;
+        if ord != Ordering::Equal {
+            return Ok(ord);
+        }
+    }
+    Ok(Ordering::Equal)
+}
+
+fn rows_equal_at(
+    left_batch: &RecordBatch,
+    left_row: usize,
+    right_batch: &RecordBatch,
+    right_row: usize,
+    indices: &[usize],
+) -> crate::Result<bool> {
+    for &idx in indices {
+        let ord = scalar_compare(
+            left_batch.column(idx),
+            left_row,
+            right_batch.column(idx),
+            right_row,
+        )?;
+        if ord != Ordering::Equal {
+            return Ok(false);
+        }
+    }
+    Ok(true)
+}
+
+fn scalar_compare(
+    left: &dyn Array,
+    left_row: usize,
+    right: &dyn Array,
+    right_row: usize,
+) -> crate::Result<Ordering> {
+    use arrow_array::{
+        BooleanArray, Date32Array, Float32Array, Float64Array, Int16Array, 
Int32Array, Int64Array,
+        Int8Array, StringArray, UInt16Array, UInt32Array, UInt64Array, 
UInt8Array,
+    };
+
+    match (left.is_null(left_row), right.is_null(right_row)) {
+        (true, true) => return Ok(Ordering::Equal),
+        (true, false) => return Ok(Ordering::Less),
+        (false, true) => return Ok(Ordering::Greater),
+        (false, false) => {}
+    }
+
+    macro_rules! compare {
+        ($ty:ty, $getter:expr) => {
+            if let (Some(a), Some(b)) = (
+                left.as_any().downcast_ref::<$ty>(),
+                right.as_any().downcast_ref::<$ty>(),
+            ) {
+                return Ok($getter(a, left_row).cmp(&$getter(b, right_row)));
+            }
+        };
+    }
+
+    compare!(Int8Array, |a: &Int8Array, r| a.value(r));
+    compare!(Int16Array, |a: &Int16Array, r| a.value(r));
+    compare!(Int32Array, |a: &Int32Array, r| a.value(r));
+    compare!(Int64Array, |a: &Int64Array, r| a.value(r));
+    compare!(UInt8Array, |a: &UInt8Array, r| a.value(r));
+    compare!(UInt16Array, |a: &UInt16Array, r| a.value(r));
+    compare!(UInt32Array, |a: &UInt32Array, r| a.value(r));
+    compare!(UInt64Array, |a: &UInt64Array, r| a.value(r));
+    compare!(BooleanArray, |a: &BooleanArray, r| a.value(r));
+    compare!(Date32Array, |a: &Date32Array, r| a.value(r));
+
+    if let (Some(a), Some(b)) = (
+        left.as_any().downcast_ref::<StringArray>(),
+        right.as_any().downcast_ref::<StringArray>(),
+    ) {
+        return Ok(a.value(left_row).cmp(b.value(right_row)));
+    }
+
+    if let (Some(a), Some(b)) = (
+        left.as_any().downcast_ref::<Float32Array>(),
+        right.as_any().downcast_ref::<Float32Array>(),
+    ) {
+        let (left, right) = (a.value(left_row), b.value(right_row));
+        return Ok(if left.is_nan() && right.is_nan() {
+            Ordering::Equal
+        } else {
+            left.total_cmp(&right)
+        });
+    }
+    if let (Some(a), Some(b)) = (
+        left.as_any().downcast_ref::<Float64Array>(),
+        right.as_any().downcast_ref::<Float64Array>(),
+    ) {
+        let (left, right) = (a.value(left_row), b.value(right_row));
+        return Ok(if left.is_nan() && right.is_nan() {
+            Ordering::Equal
+        } else {
+            left.total_cmp(&right)
+        });
+    }
+
+    Err(crate::Error::Unsupported {
+        message: format!(
+            "Batch incremental Diff does not support comparing column type 
{:?}",
+            left.data_type()
+        ),
+    })
+}
+
 /// Whether a primary-key split must go through the sort-merge reader.
 ///
 /// Mirrors Java `PrimaryKeyTableRawFileSplitReadProvider#match`: a raw read
@@ -731,4 +1499,101 @@ mod tests {
             "directly-constructed read of a query-auth.enabled table must fail 
closed"
         );
     }
+
+    #[test]
+    fn test_direct_incremental_read_fails_closed_when_query_auth_enabled() {
+        let table = query_auth_table();
+        let read = TableRead::new(&table, table.schema.fields().to_vec(), 
Vec::new());
+        let plan = IncrementalPlan::new(IncrementalScanMode::Delta, 
Vec::new());
+        assert!(
+            matches!(
+                read.to_incremental_arrow(&plan),
+                Err(crate::Error::Unsupported { ref message }) if 
message.contains("query-auth.enabled")
+            ),
+            "directly-constructed incremental read of a query-auth.enabled 
table must fail closed"
+        );
+    }
+
+    #[test]
+    fn test_direct_audit_log_read_fails_closed_when_query_auth_enabled() {
+        let table = query_auth_table();
+        let read = TableRead::new(&table, table.schema.fields().to_vec(), 
Vec::new());
+        let plan = IncrementalPlan::new(IncrementalScanMode::Delta, 
Vec::new());
+        assert!(
+            matches!(
+                read.to_audit_log_arrow(&plan),
+                Err(crate::Error::Unsupported { ref message }) if 
message.contains("query-auth.enabled")
+            ),
+            "directly-constructed audit-log read of a query-auth.enabled table 
must fail closed"
+        );
+    }
+
+    #[test]
+    fn test_diff_rejects_types_without_comparator_support() {
+        use crate::spec::{ArrayType, DecimalType, IntType, TimestampType};
+
+        let decimal = DataField::new(
+            1,
+            "amount".to_string(),
+            DataType::Decimal(DecimalType::new(10, 2).unwrap()),
+        );
+        let nested = DataField::new(
+            2,
+            "tags".to_string(),
+            DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+        );
+        let timestamp = DataField::new(
+            3,
+            "created_at".to_string(),
+            DataType::Timestamp(TimestampType::new(6).unwrap()),
+        );
+        assert!(matches!(
+            ensure_diff_supported_read_type(&[decimal]),
+            Err(crate::Error::Unsupported { message }) if 
message.contains("amount")
+        ));
+        assert!(matches!(
+            ensure_diff_supported_read_type(&[nested]),
+            Err(crate::Error::Unsupported { message }) if 
message.contains("tags")
+        ));
+        assert!(matches!(
+            ensure_diff_supported_read_type(&[timestamp]),
+            Err(crate::Error::Unsupported { message }) if 
message.contains("created_at")
+        ));
+    }
+
+    #[test]
+    fn test_diff_scalar_compare_distinguishes_null_and_nan_values() {
+        use arrow_array::{Float32Array, Int32Array};
+
+        let null = Int32Array::from(vec![None]);
+        let zero = Int32Array::from(vec![Some(0)]);
+        assert_eq!(
+            scalar_compare(&null, 0, &zero, 0).unwrap(),
+            Ordering::Less,
+            "NULL -> 0 must be reported as a changed value"
+        );
+
+        let nan = Float32Array::from(vec![f32::NAN]);
+        let one = Float32Array::from(vec![1.0]);
+        assert_ne!(
+            scalar_compare(&nan, 0, &one, 0).unwrap(),
+            Ordering::Equal,
+            "NaN must not hide a change to a finite value"
+        );
+
+        let negative_nan = 
Float32Array::from(vec![f32::from_bits(0xffc0_0001)]);
+        assert_eq!(
+            scalar_compare(&nan, 0, &negative_nan, 0).unwrap(),
+            Ordering::Equal,
+            "all NaN representations must compare equal like Java 
Float.compare"
+        );
+
+        let negative_zero = Float32Array::from(vec![-0.0]);
+        let positive_zero = Float32Array::from(vec![0.0]);
+        assert_ne!(
+            scalar_compare(&negative_zero, 0, &positive_zero, 0).unwrap(),
+            Ordering::Equal,
+            "signed zero must remain distinguishable like Java Float.compare"
+        );
+    }
 }
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index c8c0f199..b44429ef 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -681,6 +681,18 @@ fn global_index_detail_data_ranges(entries: 
&[ManifestEntry]) -> Vec<RowRange> {
     )
 }
 
+fn should_use_global_index_row_range_optimization(
+    row_range_optimization_disabled: bool,
+    data_evolution_enabled: bool,
+    global_index_enabled: bool,
+    has_data_predicates: bool,
+) -> bool {
+    !row_range_optimization_disabled
+        && data_evolution_enabled
+        && global_index_enabled
+        && has_data_predicates
+}
+
 fn should_skip_level_zero_for_scan(
     scan_all_files: bool,
     has_primary_keys: bool,
@@ -987,6 +999,20 @@ impl<'a> TableScan<'a> {
         }
     }
 
+    /// Plan before/after full-snapshot splits for batch incremental Diff.
+    pub(crate) async fn plan_snapshot_diff(
+        &self,
+        before: &Snapshot,
+        after: &Snapshot,
+    ) -> crate::Result<(Plan, Plan)> {
+        match &self.0 {
+            TableScanKind::Paimon(scan) => scan.plan_snapshot_diff(before, 
after).await,
+            TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "Format tables do not support incremental Diff 
scan".to_string(),
+            }),
+        }
+    }
+
     #[cfg(test)]
     fn apply_limit_pushdown(&self, splits: Vec<DataSplit>) -> Vec<DataSplit> {
         match &self.0 {
@@ -1009,6 +1035,9 @@ struct PaimonTableScan<'a> {
     /// When set, the scan will try to return only enough splits to satisfy 
the limit.
     limit: Option<usize>,
     row_ranges: Option<Vec<RowRange>>,
+    /// Diff compares complete logical states, so it must not accept physical
+    /// row-range pruning from an explicit range or a global-index lookup.
+    row_range_optimization_disabled: bool,
     /// When true, disables level-0 filtering so all files are visible.
     /// Used by non-read paths (overwrite, truncate, writer restore) that need
     /// the complete file set. Normal read scans leave this as `false`.
@@ -1032,6 +1061,7 @@ impl<'a> PaimonTableScan<'a> {
             bucket_predicate,
             limit,
             row_ranges,
+            row_range_optimization_disabled: false,
             scan_all_files: false,
             projected_read_field_ids: None,
         }
@@ -1060,6 +1090,12 @@ impl<'a> PaimonTableScan<'a> {
         self
     }
 
+    fn without_row_range_optimization(mut self) -> Self {
+        self.row_ranges = None;
+        self.row_range_optimization_disabled = true;
+        self
+    }
+
     pub(super) fn with_projected_read_field_ids(
         mut self,
         projected_read_field_ids: Option<HashSet<i32>>,
@@ -1299,10 +1335,12 @@ impl<'a> PaimonTableScan<'a> {
         core_options: &CoreOptions,
         data_evolution_enabled: bool,
     ) -> crate::Result<Option<GlobalIndexScanSettings>> {
-        if data_evolution_enabled
-            && core_options.global_index_enabled()
-            && !self.data_predicates.is_empty()
-        {
+        if should_use_global_index_row_range_optimization(
+            self.row_range_optimization_disabled,
+            data_evolution_enabled,
+            core_options.global_index_enabled(),
+            !self.data_predicates.is_empty(),
+        ) {
             Ok(Some(GlobalIndexScanSettings {
                 search_mode: core_options.global_index_search_mode()?,
                 thread_num: core_options.global_index_thread_num()?,
@@ -1535,6 +1573,76 @@ impl<'a> PaimonTableScan<'a> {
         .await
     }
 
+    /// Plan before/after full-snapshot states for Diff incremental scan.
+    ///
+    /// Loads full manifest entries for both snapshots, rejects bucket rescale,
+    /// then builds splits via the shared snapshot planning path. Diff keeps 
the
+    /// complete state on both sides because serialized key bytes do not 
preserve
+    /// the logical ordering required for safe overlap pruning.
+    pub(crate) async fn plan_snapshot_diff(
+        &self,
+        before: &Snapshot,
+        after: &Snapshot,
+    ) -> crate::Result<(Plan, Plan)> {
+        self.ensure_query_auth_allowed()?;
+        let core_options = CoreOptions::new(self.table.schema().options());
+        if core_options.deletion_vectors_enabled() {
+            return Err(crate::Error::Unsupported {
+                message:
+                    "Batch incremental Diff does not support tables with 
deletion-vectors.enabled=true"
+                        .to_string(),
+            });
+        }
+        if self.row_ranges.is_some() {
+            return Err(crate::Error::Unsupported {
+                message: "Batch incremental Diff does not support _ROW_ID 
row-range filters"
+                    .to_string(),
+            });
+        }
+        // A limit hint cannot be pushed into either side of a Diff: truncating
+        // the states independently can both hide changes and invent them.
+        let mut full_state_scan = self.clone();
+        full_state_scan.limit = None;
+        // Row ranges identify physical positions in individual files, whereas
+        // Diff compares complete logical states across both snapshots.
+        full_state_scan = full_state_scan.without_row_range_optimization();
+        full_state_scan
+            .validate_diff_bucket_layout(before, after)
+            .await?;
+        let before_entries = 
full_state_scan.plan_manifest_entries(before).await?;
+        let after_entries = 
full_state_scan.plan_manifest_entries(after).await?;
+        let before_plan = full_state_scan
+            .plan_snapshot_from_entries(before.clone(), before_entries, None, 
None, None, None)
+            .await?;
+        let after_plan = full_state_scan
+            .plan_snapshot_from_entries(after.clone(), after_entries, None, 
None, None, None)
+            .await?;
+        Ok((before_plan, after_plan))
+    }
+
+    async fn validate_diff_bucket_layout(
+        &self,
+        before: &Snapshot,
+        after: &Snapshot,
+    ) -> crate::Result<()> {
+        if before.schema_id() == after.schema_id() {
+            return Ok(());
+        }
+
+        let schema_manager = self.table.schema_manager();
+        let (before_schema, after_schema) = futures::try_join!(
+            schema_manager.schema(before.schema_id()),
+            schema_manager.schema(after.schema_id())
+        )?;
+        if before_schema.core_options().bucket() != 
after_schema.core_options().bucket() {
+            return Err(crate::Error::Unsupported {
+                message: "Batch incremental Diff does not support bucket 
rescale between snapshots"
+                    .to_string(),
+            });
+        }
+        Ok(())
+    }
+
     /// 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(
@@ -3147,6 +3255,44 @@ mod tests {
         )
     }
 
+    fn diff_test_table(table_path: &str, deletion_vectors_enabled: bool) -> 
Table {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let mut schema = PaimonSchema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .option("merge-engine", "deduplicate");
+        if deletion_vectors_enabled {
+            schema = schema.option("deletion-vectors.enabled", "true");
+        }
+        Table::new(
+            file_io,
+            Identifier::new("test_db", "diff_gate"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema.build().unwrap()),
+            None,
+        )
+    }
+
+    fn diff_snapshot(snapshot_id: i64) -> Snapshot {
+        diff_snapshot_with_schema(snapshot_id, 0)
+    }
+
+    fn diff_snapshot_with_schema(snapshot_id: i64, schema_id: i64) -> Snapshot 
{
+        Snapshot::builder()
+            .version(1)
+            .id(snapshot_id)
+            .schema_id(schema_id)
+            .base_manifest_list(String::new())
+            .delta_manifest_list(String::new())
+            .commit_user("test-user".to_string())
+            .commit_identifier(snapshot_id)
+            .commit_kind(CommitKind::APPEND)
+            .time_millis(snapshot_id as u64)
+            .build()
+    }
+
     fn two_int_stats_row(id: Option<i32>, value: Option<i32>) -> Vec<u8> {
         let mut builder = BinaryRowBuilder::new(2);
         match id {
@@ -4199,4 +4345,75 @@ mod tests {
             "a dynamic override must not disable query-auth"
         );
     }
+
+    #[tokio::test]
+    async fn test_diff_rejects_deletion_vectors_enabled() {
+        let table = diff_test_table("memory:/diff_dv_gate", true);
+        let scan = PaimonTableScan::new(&table, None, Vec::new(), None, None, 
None);
+        let before = diff_snapshot(1);
+        let after = diff_snapshot(2);
+
+        let err = scan.plan_snapshot_diff(&before, &after).await.unwrap_err();
+        assert!(
+            matches!(err, crate::Error::Unsupported { ref message } if 
message.contains("deletion-vectors.enabled=true")),
+            "Diff must fail closed on deletion-vector tables"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_diff_rejects_bucket_rescale_from_snapshot_schemas() {
+        let table = diff_test_table("memory:/diff_bucket_rescale_gate", false);
+        let before_schema = table.schema().clone();
+        let after_schema = before_schema
+            .apply_changes(vec![crate::spec::SchemaChange::set_option(
+                "bucket".to_string(),
+                "2".to_string(),
+            )])
+            .unwrap();
+        write_schema_file(&table, &before_schema).await;
+        write_schema_file(&table, &after_schema).await;
+
+        let scan = PaimonTableScan::new(&table, None, Vec::new(), None, None, 
None);
+        let before = diff_snapshot_with_schema(1, before_schema.id());
+        let after = diff_snapshot_with_schema(2, after_schema.id());
+
+        let err = scan.plan_snapshot_diff(&before, &after).await.unwrap_err();
+        assert!(
+            matches!(err, crate::Error::Unsupported { ref message } if 
message.contains("bucket rescale")),
+            "Diff must reject bucket rescale even when both snapshots are 
empty"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_diff_rejects_row_id_filters() {
+        let table = diff_test_table("memory:/diff_row_id_gate", false);
+        let mut builder = table.new_read_builder();
+        let filter = Predicate::Leaf {
+            column: crate::spec::ROW_ID_FIELD_NAME.to_string(),
+            index: 0,
+            data_type: DataType::BigInt(crate::spec::BigIntType::new()),
+            op: PredicateOperator::GtEq,
+            literals: vec![Datum::Long(10)],
+        };
+        builder.with_filter(filter);
+        let scan = builder.new_scan();
+        let before = diff_snapshot(1);
+        let after = diff_snapshot(2);
+
+        let err = scan.plan_snapshot_diff(&before, &after).await.unwrap_err();
+        assert!(
+            matches!(err, crate::Error::Unsupported { ref message } if 
message.contains("_ROW_ID")),
+            "Diff must reject _ROW_ID row-range filters instead of dropping 
them"
+        );
+    }
+
+    #[test]
+    fn diff_full_state_disables_global_index_row_range_optimization() {
+        assert!(!super::should_use_global_index_row_range_optimization(
+            true, true, true, true,
+        ));
+        assert!(super::should_use_global_index_row_range_optimization(
+            false, true, true, true,
+        ));
+    }
 }
diff --git a/crates/paimon/tests/audit_log_table_test.rs 
b/crates/paimon/tests/audit_log_table_test.rs
index 11fb9d48..662ccaa4 100644
--- a/crates/paimon/tests/audit_log_table_test.rs
+++ b/crates/paimon/tests/audit_log_table_test.rs
@@ -23,7 +23,7 @@ use paimon::spec::{
     DataType, IntType, Schema, TableSchema, VarCharType, ROW_KIND_FIELD_ID, 
ROW_KIND_FIELD_NAME,
     SEQUENCE_NUMBER_FIELD_NAME,
 };
-use paimon::table::{AuditLogTable, IncrementalScanMode};
+use paimon::table::{AuditLogTable, IncrementalPlan, IncrementalScanMode, 
IncrementalSplit};
 
 use common::incremental_helpers::{
     make_batch, make_batch_with_kinds, memory_table, persist_table_schema, 
pk_schema, setup_dirs,
@@ -320,9 +320,45 @@ async fn audit_log_exposes_sequence_number_when_enabled() {
     assert!(rows.iter().all(|(_, seq, _, _)| *seq >= 0));
 }
 
+async fn audit_diff_rows(
+    table: &paimon::table::Table,
+    start: i64,
+    end: i64,
+) -> Vec<(String, i32, i32)> {
+    let audit = AuditLogTable::new(table.clone());
+    let plan = audit
+        .new_incremental_scan(IncrementalScanMode::Diff, start, end)
+        .plan()
+        .await
+        .unwrap();
+    let batches: Vec<RecordBatch> = 
audit.to_arrow(&plan).unwrap().try_collect().await.unwrap();
+    collect_audit_rows(&batches)
+}
+
+fn assert_rows_contain(rows: &[(String, i32, i32)], expected: &[(&str, i32, 
i32)]) {
+    for (kind, id, value) in expected {
+        assert!(
+            rows.iter()
+                .any(|(k, i, v)| k == *kind && i == id && v == value),
+            "missing rowkind={kind} id={id} value={value} in {rows:?}"
+        );
+    }
+}
+
+fn assert_rows_exclude(rows: &[(String, i32, i32)], excluded: &[(&str, i32, 
i32)]) {
+    for (kind, id, value) in excluded {
+        assert!(
+            !rows
+                .iter()
+                .any(|(k, i, v)| k == *kind && i == id && v == value),
+            "unexpected rowkind={kind} id={id} value={value} in {rows:?}"
+        );
+    }
+}
+
 #[tokio::test]
-async fn audit_log_diff_mode_is_unsupported() {
-    let table_path = "memory:/audit_log/diff_unsupported";
+async fn audit_log_diff_scan_emits_row_level_delete_insert_and_updates() {
+    let table_path = "memory:/audit_log/diff_range";
     let (file_io, table) = memory_table(
         table_path,
         pk_schema(&[
@@ -333,17 +369,353 @@ async fn audit_log_diff_mode_is_unsupported() {
     );
     setup_dirs(&file_io, table_path).await;
     persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+    write_batch(&table, &make_batch(vec![2, 3], vec![25, 30])).await;
+
+    let rows = audit_diff_rows(&table, 1, 2).await;
+    assert_eq!(
+        rows,
+        vec![
+            ("+I".to_string(), 3, 30),
+            ("+U".to_string(), 2, 25),
+            ("-U".to_string(), 2, 20),
+        ]
+    );
+}
+
+#[tokio::test]
+async fn audit_log_diff_same_snapshot_range_returns_no_rows() {
+    let table_path = "memory:/audit_log/diff_same_snapshot";
+    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 = audit_diff_rows(&table, 1, 1).await;
+    assert!(
+        rows.is_empty(),
+        "same start/end snapshot should yield empty diff"
+    );
+}
+
+#[tokio::test]
+async fn audit_log_diff_insert_only_emits_plus_i() {
+    let table_path = "memory:/audit_log/diff_insert_only";
+    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![1, 2], vec![10, 20])).await;
+
+    let rows = audit_diff_rows(&table, 1, 2).await;
+    assert_eq!(rows, vec![("+I".to_string(), 2, 20)]);
+}
+
+#[tokio::test]
+async fn 
audit_log_diff_update_only_emits_minus_u_and_plus_u_from_before_after() {
+    let table_path = "memory:/audit_log/diff_update_only";
+    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![1], vec![20])).await;
+
+    let rows = audit_diff_rows(&table, 1, 2).await;
+    assert_eq!(
+        rows,
+        vec![("+U".to_string(), 1, 20), ("-U".to_string(), 1, 10),]
+    );
+}
+
+#[tokio::test]
+async fn audit_log_diff_delete_via_input_delete_row() {
+    // Diff compares materialized PK state; input -D removes a key without 
compact.
+    let table_path = "memory:/audit_log/diff_delete_input";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "input"),
+            ("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_with_kinds(vec![1, 2], vec![10, 20], vec![0, 0]),
+    )
+    .await;
+    write_batch(&table, &make_batch_with_kinds(vec![1], vec![10], 
vec![3])).await;
+
+    let rows = audit_diff_rows(&table, 1, 2).await;
+    assert_rows_contain(&rows, &[("-D", 1, 10)]);
+    assert_rows_exclude(&rows, &[("+I", 1, 10), ("-U", 1, 10), ("+U", 1, 10)]);
+}
+
+#[tokio::test]
+async fn audit_log_diff_mixed_delete_insert_update_without_compact() {
+    let table_path = "memory:/audit_log/diff_mixed";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "input"),
+            ("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_with_kinds(vec![1, 2, 3], vec![10, 20, 30], vec![0, 0, 0]),
+    )
+    .await;
+    write_batch(
+        &table,
+        &make_batch_with_kinds(vec![1, 2, 4], vec![10, 25, 40], vec![3, 2, 0]),
+    )
+    .await;
+
+    let rows = audit_diff_rows(&table, 1, 2).await;
+    assert_rows_contain(
+        &rows,
+        &[("-D", 1, 10), ("-U", 2, 20), ("+U", 2, 25), ("+I", 4, 40)],
+    );
+    assert_rows_exclude(&rows, &[("+I", 3, 30), ("-D", 3, 30)]);
+}
+
+#[tokio::test]
+async fn audit_log_diff_processes_multiple_bucket_pairs() {
+    let table_path = "memory:/audit_log/diff_multi_bucket";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "4"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1, 8], vec![10, 80])).await;
+    write_batch(&table, &make_batch(vec![1, 8], vec![11, 81])).await;
+
+    let plan = table
+        .new_read_builder()
+        .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+        .plan()
+        .await
+        .unwrap();
+    let diff_pairs = plan
+        .splits()
+        .iter()
+        .filter(|split| matches!(split, 
paimon::table::IncrementalSplit::DiffPair { .. }))
+        .count();
+    assert!(
+        diff_pairs >= 2,
+        "expected multiple (partition,bucket) diff pairs, got {diff_pairs}"
+    );
+
+    let rows = audit_diff_rows(&table, 1, 2).await;
+    assert_rows_contain(
+        &rows,
+        &[("-U", 1, 10), ("+U", 1, 11), ("-U", 8, 80), ("+U", 8, 81)],
+    );
+}
+
+#[tokio::test]
+async fn audit_log_diff_merges_multiple_splits_per_bucket_by_primary_key() {
+    let table_path = "memory:/audit_log/diff_multi_split_bucket";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+            ("target-file-size", "1b"),
+            ("source.split.target-size", "1b"),
+            ("source.split.open-file-cost", "1b"),
+            ("num-sorted-run.compaction-trigger", "100"),
+        ]),
+    );
+    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![3], vec![30])).await;
+    write_batch(&table, &make_batch(vec![1], vec![11])).await;
+
+    let audit = AuditLogTable::new(table.clone());
+    let plan = audit
+        .new_incremental_scan(IncrementalScanMode::Diff, 2, 3)
+        .plan()
+        .await
+        .unwrap();
+    let scrambled = plan
+        .splits()
+        .iter()
+        .cloned()
+        .map(|split| match split {
+            IncrementalSplit::DiffPair { mut before, after } => {
+                assert!(before.len() >= 2, "test requires multiple before 
splits");
+                assert!(after.len() >= 2, "test requires multiple after 
splits");
+                before.reverse();
+                IncrementalSplit::DiffPair { before, after }
+            }
+            other => other,
+        })
+        .collect();
+    let scrambled = IncrementalPlan::try_new(IncrementalScanMode::Diff, 
scrambled).unwrap();
+
+    let batches: Vec<RecordBatch> = audit
+        .to_arrow(&scrambled)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(
+        collect_audit_rows(&batches),
+        vec![("+U".to_string(), 1, 11), ("-U".to_string(), 1, 10),]
+    );
+}
+
+#[tokio::test]
+async fn audit_log_diff_with_sequence_number_enabled_exposes_ordered_columns() 
{
+    use std::collections::HashMap;
+
+    let table_path = "memory:/audit_log/diff_sequence";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+        ])
+        .copy_with_options(HashMap::from([(
+            "table-read.sequence-number.enabled".to_string(),
+            "true".to_string(),
+        )])),
+    );
+    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![1], vec![20])).await;
 
     let audit = AuditLogTable::new(table.clone());
-    let err = audit
+    let field_names: Vec<String> = audit
+        .fields()
+        .unwrap()
+        .into_iter()
+        .map(|f| f.name().to_string())
+        .collect();
+    assert_eq!(
+        field_names,
+        vec![
+            "rowkind".to_string(),
+            SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+            "id".to_string(),
+            "value".to_string(),
+        ]
+    );
+
+    let plan = audit
         .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
         .plan()
         .await
-        .unwrap_err();
+        .unwrap();
+    let batches: Vec<RecordBatch> = 
audit.to_arrow(&plan).unwrap().try_collect().await.unwrap();
+    let rows = collect_audit_rows_with_sequence(&batches);
+    assert_eq!(rows.len(), 2);
+    assert_rows_contain(
+        &rows
+            .iter()
+            .map(|(k, _s, i, v)| (k.clone(), *i, *v))
+            .collect::<Vec<_>>(),
+        &[("-U", 1, 10), ("+U", 1, 20)],
+    );
+    assert!(rows.iter().all(|(_, seq, _, _)| *seq >= 0));
+}
+
+#[tokio::test]
+async fn audit_log_rejects_invalid_incremental_plan_at_consumption() {
+    let table_path = "memory:/audit_log/invalid_incremental_plan";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[("merge-engine", "deduplicate"), ("bucket", "1")]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+    let audit = AuditLogTable::new(table.clone());
+
+    let invalid_kind = IncrementalPlan::new(
+        IncrementalScanMode::Delta,
+        vec![IncrementalSplit::DiffPair {
+            before: Vec::new(),
+            after: Vec::new(),
+        }],
+    );
+    let err = match audit.to_arrow(&invalid_kind) {
+        Ok(_) => panic!("invalid plans must fail instead of producing an empty 
audit stream"),
+        Err(err) => err,
+    };
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("DiffPair")),
+        "invalid plans must fail instead of producing an empty audit stream: 
{err:?}"
+    );
+
+    let auto = IncrementalPlan::new(IncrementalScanMode::Auto, Vec::new());
+    let err = match audit.to_arrow(&auto) {
+        Ok(_) => panic!("Auto plans must fail at consumption"),
+        Err(err) => err,
+    };
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("Auto")),
+        "Auto plans must fail at consumption: {err:?}"
+    );
+
+    let err = IncrementalPlan::try_new(IncrementalScanMode::Auto, 
Vec::new()).unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("Auto")),
+        "try_new must reject unresolved Auto plans: {err:?}"
+    );
+
+    let read = table.new_read_builder().new_read().unwrap();
+    let err = match read.to_incremental_arrow(&auto) {
+        Ok(_) => panic!("the direct incremental reader must validate plans 
too"),
+        Err(err) => err,
+    };
     assert!(
-        matches!(err, paimon::Error::Unsupported { .. }),
-        "expected Unsupported for Diff audit plan, got {err:?}"
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("Auto")),
+        "the direct incremental reader must validate plans too: {err:?}"
     );
 }
diff --git a/crates/paimon/tests/incremental_batch_scan_test.rs 
b/crates/paimon/tests/incremental_batch_scan_test.rs
index bc007851..5fe3cdcb 100644
--- a/crates/paimon/tests/incremental_batch_scan_test.rs
+++ b/crates/paimon/tests/incremental_batch_scan_test.rs
@@ -429,10 +429,9 @@ async fn 
incremental_changelog_scan_applies_partition_filter_from_read_builder()
     assert_eq!(collect_pairs(&batches), vec![(1, 10)]);
 }
 
-/// Diff mode remains unsupported in this PR.
 #[tokio::test]
-async fn diff_mode_is_unsupported() {
-    let table_path = "memory:/incremental_batch/diff_unsupported";
+async fn diff_between_snapshots_returns_after_image_rows() {
+    let table_path = "memory:/incremental_batch/diff_after_image";
     let (file_io, table) = memory_table(
         table_path,
         pk_schema(&[
@@ -443,15 +442,578 @@ async fn diff_mode_is_unsupported() {
     );
     setup_dirs(&file_io, table_path).await;
     persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+    write_batch(&table, &make_batch(vec![2, 3], vec![25, 30])).await;
+
+    let rows = read_incremental_pairs(&table, IncrementalScanMode::Diff, 1, 
2).await;
+    assert_eq!(rows, vec![(2, 25), (3, 30)]);
+}
+
+#[tokio::test]
+async fn diff_identical_rows_are_skipped_from_after_image() {
+    let table_path = "memory:/incremental_batch/diff_identical";
+    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![1, 2], vec![10, 20])).await;
+
+    let rows = read_incremental_pairs(&table, IncrementalScanMode::Diff, 1, 
2).await;
+    assert_eq!(rows, vec![(2, 20)]);
+}
+
+#[tokio::test]
+async fn diff_projection_without_primary_key_still_compares_full_rows() {
+    let table_path = "memory:/incremental_batch/diff_projection_without_pk";
+    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![1], vec![20])).await;
+
+    let mut builder = table.new_read_builder();
+    builder.with_projection(&["value"]).unwrap();
+    let plan = builder
+        .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+        .plan()
+        .await
+        .unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_incremental_arrow(&plan)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let values: Vec<i32> = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .iter()
+                .copied()
+        })
+        .collect();
+    assert_eq!(values, vec![20]);
+}
+
+#[tokio::test]
+async fn diff_change_outside_projection_is_not_missed() {
+    let table_path = "memory:/incremental_batch/diff_unprojected_change";
+    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![1], vec![20])).await;
+
+    let mut builder = table.new_read_builder();
+    builder.with_projection(&["id"]).unwrap();
+    let plan = builder
+        .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+        .plan()
+        .await
+        .unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_incremental_arrow(&plan)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let ids: Vec<i32> = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .iter()
+                .copied()
+        })
+        .collect();
+    assert_eq!(ids, vec![1]);
+}
+
+#[tokio::test]
+async fn diff_null_to_zero_is_reported_as_change() {
+    use arrow_schema::{DataType as ArrowDataType, Field, Schema as 
ArrowSchema};
+    use paimon::spec::{DataType, IntType, Schema, TableSchema};
+    use std::sync::Arc;
+
+    let table_path = "memory:/incremental_batch/diff_null_to_zero";
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::with_nullable(true)))
+        .primary_key(["id"])
+        .option("changelog-producer", "none")
+        .option("merge-engine", "deduplicate")
+        .option("bucket", "1")
+        .option("bucket-key", "id")
+        .build()
+        .unwrap();
+    let (file_io, table) = memory_table(table_path, TableSchema::new(0, 
&schema));
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    let make_nullable_batch = |value| {
+        RecordBatch::try_new(
+            Arc::new(ArrowSchema::new(vec![
+                Field::new("id", ArrowDataType::Int32, false),
+                Field::new("value", ArrowDataType::Int32, true),
+            ])),
+            vec![
+                Arc::new(Int32Array::from(vec![1])),
+                Arc::new(Int32Array::from(vec![value])),
+            ],
+        )
+        .unwrap()
+    };
+    write_batch(&table, &make_nullable_batch(None)).await;
+    write_batch(&table, &make_nullable_batch(Some(0))).await;
+
+    let rows = read_incremental_pairs(&table, IncrementalScanMode::Diff, 1, 
2).await;
+    assert_eq!(rows, vec![(1, 0)]);
+}
+
+#[tokio::test]
+async fn diff_ignores_scan_limit_when_planning_full_states() {
+    let table_path = "memory:/incremental_batch/diff_limit";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "4"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1, 8], vec![10, 80])).await;
+    write_batch(&table, &make_batch(vec![1, 8], vec![11, 81])).await;
+
+    let mut builder = table.new_read_builder();
+    builder.with_limit(1);
+    let plan = builder
+        .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+        .plan()
+        .await
+        .unwrap();
+    let pair_count = plan
+        .splits()
+        .iter()
+        .filter(|split| matches!(split, 
paimon::table::IncrementalSplit::DiffPair { .. }))
+        .count();
+    assert!(pair_count >= 2, "limit must not truncate Diff state pairs");
+
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_incremental_arrow(&plan)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(collect_pairs(&batches), vec![(1, 11), (8, 81)]);
+}
+
+#[tokio::test]
+async fn diff_empty_projection_preserves_changed_row_count() {
+    let table_path = "memory:/incremental_batch/diff_empty_projection";
+    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![1], vec![20])).await;
+
+    let mut builder = table.new_read_builder();
+    builder.with_projection(&[]).unwrap();
+    let plan = builder
+        .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+        .plan()
+        .await
+        .unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_incremental_arrow(&plan)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+    assert!(batches.iter().all(|batch| batch.num_columns() == 0));
+}
+
+#[tokio::test]
+async fn diff_rejects_row_ranges_instead_of_dropping_them() {
+    use paimon::table::RowRange;
+
+    let table_path = "memory:/incremental_batch/diff_row_ranges";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+            ("row-tracking.enabled", "true"),
+            ("target-file-size", "1b"),
+            ("source.split.target-size", "1b"),
+            ("source.split.open-file-cost", "1b"),
+            ("num-sorted-run.compaction-trigger", "100"),
+        ]),
+    );
+    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![3], vec![30])).await;
+    write_batch(&table, &make_batch(vec![1], vec![11])).await;
+
+    let mut builder = table.new_read_builder();
+    builder.with_row_ranges(vec![RowRange::new(1, 2)]);
+    let err = builder
+        .new_incremental_scan(IncrementalScanMode::Diff, 2, 3)
+        .plan()
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(
+            err,
+            paimon::Error::Unsupported { ref message } if 
message.contains("_ROW_ID")
+        ),
+        "Diff must reject _ROW_ID row-range filters instead of dropping them: 
{err:?}"
+    );
+}
+
+#[tokio::test]
+async fn diff_reads_more_than_128_files_in_one_side() {
+    let table_path = "memory:/incremental_batch/diff_many_files";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "none"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+            ("target-file-size", "1b"),
+            ("source.split.target-size", "1b"),
+            ("source.split.open-file-cost", "1b"),
+            ("num-sorted-run.compaction-trigger", "1000"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    for id in 0..129 {
+        write_batch(&table, &make_batch(vec![id], vec![10])).await;
+    }
+    write_batch(&table, &make_batch(vec![0], vec![11])).await;
+
+    let plan = plan_incremental(&table, IncrementalScanMode::Diff, 129, 130)
+        .await
+        .unwrap();
+    let file_count = plan
+        .splits()
+        .iter()
+        .map(|split| match split {
+            paimon::table::IncrementalSplit::DiffPair { before, .. } => before
+                .iter()
+                .map(|split| split.data_files().len())
+                .sum::<usize>(),
+            paimon::table::IncrementalSplit::Data(_) => 0,
+        })
+        .sum::<usize>();
+    assert!(file_count > 128, "test requires more than 128 before files");
+
+    assert_eq!(
+        read_incremental_pairs(&table, IncrementalScanMode::Diff, 129, 
130).await,
+        vec![(0, 11)]
+    );
+}
+
+#[tokio::test]
+async fn diff_rejects_start_before_earliest_snapshot() {
+    let table_path = "memory:/incremental_batch/diff_earliest";
+    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::Diff, 0, 1)
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { .. }),
+        "expected DataInvalid, got {err:?}"
+    );
+}
+
+#[tokio::test]
+async fn diff_rejects_non_deduplicate_merge_engine() {
+    for merge_engine in ["partial-update", "aggregation", "first-row"] {
+        let table_path = 
format!("memory:/incremental_batch/diff_engine_{merge_engine}");
+        let (file_io, table) = memory_table(
+            &table_path,
+            pk_schema(&[
+                ("changelog-producer", "none"),
+                ("merge-engine", merge_engine),
+                ("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 err = plan_incremental(&table, IncrementalScanMode::Diff, 1, 2)
+            .await
+            .unwrap_err();
+        assert!(
+            matches!(err, paimon::Error::Unsupported { .. }),
+            "merge-engine={merge_engine} expected Unsupported, got {err:?}"
+        );
+    }
+}
+
+#[tokio::test]
+async fn diff_rejects_table_without_primary_keys() {
+    use paimon::spec::{DataType, IntType, Schema, TableSchema};
+
+    let table_path = "memory:/incremental_batch/diff_without_primary_keys";
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .option("changelog-producer", "none")
+        .option("merge-engine", "deduplicate")
+        .option("bucket", "1")
+        .option("bucket-key", "id")
+        .build()
+        .unwrap();
+    let (file_io, table) = memory_table(table_path, TableSchema::new(0, 
&schema));
+    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 err = plan_incremental(&table, IncrementalScanMode::Diff, 1, 2)
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::Unsupported { ref message } if 
message.contains("primary keys")),
+        "expected Unsupported for a table without primary keys, got {err:?}"
+    );
+}
+
+#[test]
+fn incremental_plan_rejects_data_split_in_diff_mode() {
+    use paimon::spec::BinaryRow;
+    use paimon::table::{DataSplitBuilder, IncrementalPlan, IncrementalSplit};
+
+    let split = DataSplitBuilder::new()
+        .with_snapshot(1)
+        .with_partition(BinaryRow::new(0))
+        .with_bucket(0)
+        .with_bucket_path("memory:/incremental_batch/bucket-0".to_string())
+        .with_total_buckets(1)
+        .with_data_files(Vec::new())
+        .build()
+        .unwrap();
+    let err = IncrementalPlan::try_new(
+        IncrementalScanMode::Diff,
+        vec![IncrementalSplit::Data(split)],
+    )
+    .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("Data split")),
+        "Diff plan must reject Data splits instead of silently skipping them: 
{err:?}"
+    );
+}
+
+#[test]
+fn incremental_plan_rejects_diff_pair_with_mismatched_bucket_metadata() {
+    use paimon::spec::BinaryRow;
+    use paimon::table::{DataSplitBuilder, IncrementalPlan, IncrementalSplit};
+
+    let split = |bucket| {
+        DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(bucket)
+            
.with_bucket_path(format!("memory:/incremental_batch/bucket-{bucket}"))
+            .with_total_buckets(2)
+            .with_data_files(Vec::new())
+            .build()
+            .unwrap()
+    };
+    let err = IncrementalPlan::try_new(
+        IncrementalScanMode::Diff,
+        vec![IncrementalSplit::DiffPair {
+            before: vec![split(0)],
+            after: vec![split(1)],
+        }],
+    )
+    .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("partition buckets")),
+        "Diff plan must reject pairs that cross partition buckets: {err:?}"
+    );
+}
+
+#[test]
+fn incremental_plan_rejects_partial_or_inconsistent_diff_states() {
+    use paimon::spec::BinaryRow;
+    use paimon::table::{DataSplitBuilder, IncrementalPlan, IncrementalSplit, 
RowRange};
+
+    let split = |snapshot, with_row_ranges| {
+        let mut builder = DataSplitBuilder::new()
+            .with_snapshot(snapshot)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path("memory:/incremental_batch/bucket-0".to_string())
+            .with_total_buckets(1)
+            .with_data_files(Vec::new());
+        if with_row_ranges {
+            builder = builder.with_row_ranges(vec![RowRange::new(0, 1)]);
+        }
+        builder.build().unwrap()
+    };
+
+    let err = IncrementalPlan::try_new(
+        IncrementalScanMode::Diff,
+        vec![IncrementalSplit::DiffPair {
+            before: vec![split(1, true)],
+            after: vec![split(2, false)],
+        }],
+    )
+    .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("row ranges")),
+        "Diff plan must reject partial physical row ranges: {err:?}"
+    );
+
+    let err = IncrementalPlan::try_new(
+        IncrementalScanMode::Diff,
+        vec![IncrementalSplit::DiffPair {
+            before: vec![split(2, false)],
+            after: vec![split(1, false)],
+        }],
+    )
+    .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("earlier")),
+        "Diff plan must reject reversed snapshot states: {err:?}"
+    );
+
+    let err = IncrementalPlan::try_new(
+        IncrementalScanMode::Diff,
+        vec![
+            IncrementalSplit::DiffPair {
+                before: vec![split(1, false)],
+                after: Vec::new(),
+            },
+            IncrementalSplit::DiffPair {
+                before: vec![split(2, false)],
+                after: Vec::new(),
+            },
+        ],
+    )
+    .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. } if 
message.contains("before snapshots")),
+        "Diff plan must reject mixed before snapshots: {err:?}"
+    );
+}
+
+#[tokio::test]
+async fn diff_rejects_bucket_rescale_between_snapshots() {
+    use paimon::spec::SchemaChange;
+
+    let table_path = "memory:/incremental_batch/diff_bucket_rescale";
+    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 schema = table
+        .schema()
+        .apply_changes(vec![SchemaChange::set_option(
+            "bucket".to_string(),
+            "2".to_string(),
+        )])
+        .unwrap();
+    let table = paimon::table::Table::new(
+        file_io.clone(),
+        table.identifier().clone(),
+        table_path.to_string(),
+        schema,
+        None,
+    );
+    persist_table_schema(&file_io, table_path, table.schema()).await;
     write_batch(&table, &make_batch(vec![2], vec![20])).await;
 
-    // Non-empty range so planning reaches plan_diff (empty range 
short-circuits).
     let err = plan_incremental(&table, IncrementalScanMode::Diff, 1, 2)
         .await
         .unwrap_err();
     assert!(
-        matches!(err, paimon::Error::Unsupported { .. }),
-        "expected Unsupported for Diff, got {err:?}"
+        matches!(
+            err,
+            paimon::Error::Unsupported { ref message } if 
message.contains("bucket rescale")
+        ),
+        "expected Unsupported for bucket rescale, got {err:?}"
     );
 }
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 214293d0..6c3ec41c 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -48,6 +48,7 @@ theme:
 nav:
   - Home: index.md
   - Getting Started: getting-started.md
+  - Batch Incremental Reading: incremental-reading.md
   - SQL Integration: sql.md
   - Performance: benchmark.md
   - C Integration: c-binding.md
diff --git a/docs/src/incremental-reading.md b/docs/src/incremental-reading.md
new file mode 100644
index 00000000..36e43db1
--- /dev/null
+++ b/docs/src/incremental-reading.md
@@ -0,0 +1,94 @@
+<!--
+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.
+-->
+
+# Batch Incremental Reading
+
+The Rust API can plan and read changes between snapshot IDs. Snapshot ranges 
use
+`(start_exclusive, end_inclusive]` semantics. For example, `(3, 5]` includes
+snapshots 4 and 5.
+
+## Scan Modes
+
+| Mode | Behavior |
+|------|----------|
+| `IncrementalScanMode::Delta` | Reads data files added by `APPEND` snapshots 
in the range. |
+| `IncrementalScanMode::Changelog` | Reads existing changelog files. It skips 
`OVERWRITE` snapshots and snapshots without changelog files. |
+| `IncrementalScanMode::Auto` | Uses `Delta` when `changelog-producer=none`; 
otherwise uses `Changelog`. |
+| `IncrementalScanMode::Diff` | Compares the complete table states at the 
start and end snapshots. |
+
+`Changelog` mode does not generate missing changelog files. Configure a
+`changelog-producer` when writing the table if changelog reads are required.
+
+## Read Incremental Rows
+
+Build the incremental plan and pass it to `TableRead::to_incremental_arrow`:
+
+```rust
+use futures::TryStreamExt;
+use paimon::IncrementalScanMode;
+
+let read_builder = table.new_read_builder();
+let plan = read_builder
+    .new_incremental_scan(IncrementalScanMode::Diff, 3, 5)
+    .plan()
+    .await?;
+
+let reader = read_builder.new_read()?;
+let batches = reader
+    .to_incremental_arrow(&plan)?
+    .try_collect::<Vec<_>>()
+    .await?;
+```
+
+`Delta` and `Changelog` return rows from their planned files. `Diff` returns
+after-image rows for inserted or updated keys and omits deleted keys. 
Projection
+and filters configured on the read builder are applied to the output; `Diff`
+still compares complete rows before applying projection.
+
+## Read Audit-Log Rows
+
+Use `TableRead::to_audit_log_arrow` when the output must include row kinds:
+
+```rust
+let reader = read_builder.new_read()?;
+let batches = reader
+    .to_audit_log_arrow(&plan)?
+    .try_collect::<Vec<_>>()
+    .await?;
+```
+
+The first output column is `rowkind`. `Diff` emits `+I`, `-U`, `+U`, and `-D`
+records by comparing the before and after images. If table option
+`table-read.sequence-number.enabled=true` is set, `_SEQUENCE_NUMBER` follows
+`rowkind`.
+
+## Diff Restrictions
+
+`Diff` currently:
+
+- requires a primary-key table with `merge-engine=deduplicate`;
+- does not support deletion vectors or bucket rescaling between the two
+  snapshots;
+- supports `BOOLEAN`, integer, floating-point, character, string, and `DATE`
+  columns;
+- uses table option `diff.parallelism` to control concurrent
+  partition-and-bucket comparisons (default: `4`, minimum: `1`).
+
+The start snapshot must still exist because `Diff` reads both endpoint states.
+An equal start and end snapshot produces an empty result.

Reply via email to