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 0132d488 feat(core): align batch changelog planning and row-kind 
normalization (#1079)
0132d488 is described below

commit 0132d488756ab2b3a388f3312332096bbe74647b
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 23:09:46 2026 +0800

    feat(core): align batch changelog planning and row-kind normalization 
(#1079)
---
 bindings/python/tests/test_read.py                 |  34 ++
 crates/paimon/src/table/incremental_scan.rs        | 101 ++---
 crates/paimon/src/table/read_builder.rs            |   6 +-
 crates/paimon/src/table/scan_trace.rs              |  34 --
 crates/paimon/src/table/source.rs                  |   4 -
 crates/paimon/src/table/table_scan.rs              |  29 +-
 crates/paimon/src/table/table_write.rs             | 202 ++++++++--
 .../tests/incremental_changelog_batch_test.rs      | 443 +++++++++++++++++++++
 crates/paimon/tests/writer_resources_test.rs       |  49 ++-
 9 files changed, 755 insertions(+), 147 deletions(-)

diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index 1a88cbe2..abd0cf16 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -1547,6 +1547,40 @@ def 
test_incremental_changelog_and_auto_plan_physical_changelog_files():
         assert empty.splits() == []
 
 
[email protected]("mode", ["changelog", "auto"])
+def test_batch_incremental_changelog_packing_sharding_and_limits(mode):
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_input_changelog_table(warehouse)
+        builder = table.new_read_builder().with_include_row_kind(True)
+        full = builder.new_incremental_scan(0, 2, mode).plan()
+        # Java packs changelogs over the whole range, rather than once per
+        # snapshot. The two commits in this bucket fit into one split.
+        assert len(full.splits()) == 1
+        assert len(_native_split_file_names(full.splits())) == 2
+        names, rows = [], []
+        for index in range(3):
+            scan = builder.new_incremental_scan(0, 2, mode).with_shard(index, 
3)
+            plan = scan.plan()
+            assert plan.snapshot_id() == 2
+            assert all(split.is_streaming() for split in plan.splits())
+            names.extend(_native_split_file_names(plan.splits()))
+            batches = builder.new_read().read(plan.splits())
+            if batches:
+                rows.extend(pa.Table.from_batches(batches).to_pylist())
+            for boundary in (0, 2):
+                empty = builder.new_incremental_scan(boundary, boundary, 
mode).with_shard(index, 3).plan()
+                assert empty.snapshot_id() == boundary
+                assert empty.splits() == []
+        assert sorted(names) == sorted(_native_split_file_names(full.splits()))
+        assert sorted((row['rowkind'], row['id'], row['value']) for row in 
rows) == [
+            ('+I', 1, 'a'), ('+I', 2, 'b'), ('+I', 3, 'c')]
+        limited = 
(table.new_read_builder().with_limit(1).with_include_row_kind(True))
+        plan = limited.new_incremental_scan(0, 2, mode).with_shard(0, 3).plan()
+        result = 
pa.Table.from_batches(limited.new_read().read(plan.splits())).to_pylist()
+        assert len(result) == 1
+        assert result[0] in rows
+
+
 def test_incremental_changelog_keeps_filter_projection_and_validates_mode():
     with tempfile.TemporaryDirectory() as warehouse:
         table = _make_input_changelog_table(warehouse)
diff --git a/crates/paimon/src/table/incremental_scan.rs 
b/crates/paimon/src/table/incremental_scan.rs
index 7d30fd73..acddf033 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -301,6 +301,7 @@ impl<'a> IncrementalScan<'a> {
     }
 
     /// Select one worker shard using the same rules as 
[`TableScan::with_shard`].
+    /// Use [`Self::plan_combined`] for Delta and Changelog batch sharding.
     pub fn with_shard(mut self, index: usize, count: usize) -> 
crate::Result<Self> {
         self.scan = self.scan.with_shard(index, count)?;
         Ok(self)
@@ -314,7 +315,7 @@ impl<'a> IncrementalScan<'a> {
         }
         match mode {
             IncrementalScanMode::Delta => self.plan_delta(mode).await,
-            IncrementalScanMode::Changelog => self.plan_changelog(mode, 
None).await,
+            IncrementalScanMode::Changelog => self.plan_changelog(mode).await,
             IncrementalScanMode::Auto => unreachable!("Auto must resolve 
before planning"),
             IncrementalScanMode::Diff => self.plan_diff(mode).await,
         }
@@ -327,7 +328,7 @@ impl<'a> IncrementalScan<'a> {
             || self.scan.has_shard()
         {
             return Err(crate::Error::Unsupported {
-                message: "Incremental row-position selection, chunk_shuffle 
and sharding require combined delta planning"
+                message: "Incremental row-position selection and chunk_shuffle 
require combined Delta planning; sharding requires combined Delta or Changelog 
planning"
                     .into(),
             });
         }
@@ -338,8 +339,8 @@ impl<'a> IncrementalScan<'a> {
     ///
     /// This is the bridge used by readers which already consume
     /// [`DataSplit`]s. Delta keeps its cross-snapshot packing semantics;
-    /// Changelog preserves physical changelog files and row kinds in snapshot
-    /// order. `Auto` resolves from `changelog-producer`. Diff cannot be
+    /// Changelog packs physical changelog files across snapshots while 
preserving
+    /// every row kind. `Auto` resolves from `changelog-producer`. Diff cannot 
be
     /// represented by an ordinary split list because each unit contains a
     /// before/after pair.
     pub async fn plan_combined(&self) -> crate::Result<Plan> {
@@ -350,25 +351,10 @@ impl<'a> IncrementalScan<'a> {
         &self,
         trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Plan> {
-        match self.resolve_mode() {
-            IncrementalScanMode::Delta => 
self.plan_combined_delta_with_trace(trace).await,
-            IncrementalScanMode::Changelog => {
-                
self.validate_per_snapshot_plan(IncrementalScanMode::Changelog).await?;
-                let incremental = 
self.plan_changelog(IncrementalScanMode::Changelog, trace).await?;
-                let mut splits = 
Vec::with_capacity(incremental.splits().len());
-                for split in incremental.splits() {
-                    match split {
-                        IncrementalSplit::Data(split) => 
splits.push(split.clone().with_snapshot_id(self.end_inclusive)),
-                        IncrementalSplit::DiffPair { .. } => {
-                            return Err(crate::Error::UnexpectedError {
-                                message: "DiffPair appeared in a Changelog 
incremental plan"
-                                    .to_string(),
-                                source: None,
-                            });
-                        }
-                    }
-                }
-                Ok(Plan::new(splits).with_snapshot_id(self.end_inclusive))
+        let mode = self.resolve_mode();
+        match mode {
+            IncrementalScanMode::Delta | IncrementalScanMode::Changelog => {
+                self.plan_combined_events(mode, trace).await
             }
             IncrementalScanMode::Diff => Err(crate::Error::Unsupported {
                 message: "Combined incremental planning does not support Diff 
mode; Diff requires before/after split pairs"
@@ -387,39 +373,73 @@ impl<'a> IncrementalScan<'a> {
     /// must exist and supplies snapshot metadata. Snapshot deletion vectors 
and
     /// automatic global-index pruning do not apply to these historical events.
     pub async fn plan_combined_delta(&self) -> crate::Result<Plan> {
-        self.plan_combined_delta_with_trace(None).await
+        
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        let mode = self.resolve_mode();
+        if mode != IncrementalScanMode::Delta {
+            return Err(crate::Error::Unsupported {
+                message: "Combined incremental planning only supports Delta 
mode".to_string(),
+            });
+        }
+        self.plan_combined_events(mode, None).await
     }
 
-    async fn plan_combined_delta_with_trace(
+    async fn plan_combined_events(
         &self,
+        mode: IncrementalScanMode,
         trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Plan> {
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
-        let mode = self.resolve_mode();
-        if mode != IncrementalScanMode::Delta {
+        // Row positions and shuffled chunks refer to one APPEND-delta row
+        // space. Changelog supports Java's file/bucket shard selection only.
+        if mode == IncrementalScanMode::Changelog
+            && (self.scan.has_row_position_selection() || 
self.scan.has_chunk_shuffle())
+        {
             return Err(crate::Error::Unsupported {
-                message: "Combined incremental planning only supports Delta 
mode".to_string(),
+                message: "Changelog batch planning does not support 
row-position selection or chunk_shuffle".into(),
             });
         }
         self.validate_snapshot_range(mode).await?;
+        if mode == IncrementalScanMode::Changelog && self.start_exclusive == 
self.end_inclusive {
+            if let Some(trace) = trace {
+                trace.record_final_plan(0, 0, 0);
+            }
+            // Changelog's empty range may end at earliest-1, which is a
+            // valid boundary even though that snapshot need not exist.
+            return 
Ok(Plan::new(Vec::new()).with_snapshot_id(self.end_inclusive));
+        }
         let end_snapshot = self
             .snapshot_manager
             .get_snapshot(self.end_inclusive)
             .await?;
         let mut snapshots = Vec::new();
+        let selected = |snapshot: &crate::spec::Snapshot| match mode {
+            IncrementalScanMode::Delta => snapshot.commit_kind() == 
&CommitKind::APPEND,
+            IncrementalScanMode::Changelog => {
+                snapshot.commit_kind() != &CommitKind::OVERWRITE
+                    && snapshot.changelog_manifest_list().is_some()
+            }
+            _ => unreachable!("combined event planning only accepts Delta or 
Changelog"),
+        };
         for snapshot_id in (self.start_exclusive + 1)..self.end_inclusive {
             let snapshot = 
self.snapshot_manager.get_snapshot(snapshot_id).await?;
-            if snapshot.commit_kind() == &CommitKind::APPEND {
+            if selected(&snapshot) {
                 snapshots.push(snapshot);
             }
         }
-        if self.start_exclusive < self.end_inclusive
-            && end_snapshot.commit_kind() == &CommitKind::APPEND
-        {
+        if self.start_exclusive < self.end_inclusive && 
selected(&end_snapshot) {
             snapshots.push(end_snapshot.clone());
         }
+        let manifest_lists = snapshots
+            .iter()
+            .filter_map(|snapshot| match mode {
+                IncrementalScanMode::Delta => 
Some(snapshot.delta_manifest_list()),
+                IncrementalScanMode::Changelog => 
snapshot.changelog_manifest_list(),
+                _ => unreachable!("combined event planning only accepts Delta 
or Changelog"),
+            })
+            .map(str::to_owned)
+            .collect();
         self.scan
-            .plan_snapshot_deltas_with_trace(&snapshots, &end_snapshot, trace)
+            .plan_incremental_lists_with_trace(&end_snapshot, manifest_lists, 
trace)
             .await
     }
 
@@ -488,11 +508,7 @@ impl<'a> IncrementalScan<'a> {
         IncrementalPlan::try_new(mode, splits)
     }
 
-    async fn plan_changelog(
-        &self,
-        mode: IncrementalScanMode,
-        mut trace: Option<&mut ScanTrace>,
-    ) -> crate::Result<IncrementalPlan> {
+    async fn plan_changelog(&self, mode: IncrementalScanMode) -> 
crate::Result<IncrementalPlan> {
         let mut splits = Vec::new();
         for snapshot_id in (self.start_exclusive + 1)..=self.end_inclusive {
             let snapshot = 
self.snapshot_manager.get_snapshot(snapshot_id).await?;
@@ -504,17 +520,10 @@ impl<'a> IncrementalScan<'a> {
             if snapshot.changelog_manifest_list().is_none() {
                 continue;
             }
-            let mut snapshot_trace = ScanTrace::default();
             let plan = self
                 .scan
-                .plan_snapshot_changelog_with_trace(
-                    &snapshot,
-                    trace.as_ref().map(|_| &mut snapshot_trace),
-                )
+                .plan_snapshot_changelog_with_trace(&snapshot, None)
                 .await?;
-            if let Some(trace) = trace.as_deref_mut() {
-                trace.accumulate(&snapshot_trace);
-            }
             
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
         }
         IncrementalPlan::try_new(mode, splits)
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index ce971a17..ea327b31 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -307,9 +307,9 @@ impl<'a> ReadBuilder<'a> {
     /// `(start_exclusive, end_inclusive]`.
     ///
     /// Filters and projection configured on this builder are pushed into the
-    /// incremental plan (partition / bucket pruning on the delta path).
-    /// Use [`IncrementalScan::plan_combined_delta`] to merge APPEND deltas 
into
-    /// one ordinary batch plan instead of preserving each snapshot separately.
+    /// incremental plan (including partition / bucket pruning).
+    /// Use [`IncrementalScan::plan_combined`] to pack DELTA or CHANGELOG files
+    /// across the range into one batch plan.
     pub fn new_incremental_scan(
         &self,
         mode: IncrementalScanMode,
diff --git a/crates/paimon/src/table/scan_trace.rs 
b/crates/paimon/src/table/scan_trace.rs
index 8cd8aa25..45063800 100644
--- a/crates/paimon/src/table/scan_trace.rs
+++ b/crates/paimon/src/table/scan_trace.rs
@@ -59,40 +59,6 @@ pub struct ScanTrace {
 }
 
 impl ScanTrace {
-    /// Add counters from another independently planned changelog snapshot.
-    pub(crate) fn accumulate(&mut self, other: &Self) {
-        self.base_manifest_files += other.base_manifest_files;
-        self.delta_manifest_files += other.delta_manifest_files;
-        self.manifest_files_before_partition_pruning +=
-            other.manifest_files_before_partition_pruning;
-        self.manifest_files_after_partition_pruning += 
other.manifest_files_after_partition_pruning;
-        self.manifest_files_pruned_by_row_ranges += 
other.manifest_files_pruned_by_row_ranges;
-        self.manifest_entries_read += other.manifest_entries_read;
-        self.manifest_entries_pruned_by_bucket += 
other.manifest_entries_pruned_by_bucket;
-        self.manifest_entries_pruned_by_partition += 
other.manifest_entries_pruned_by_partition;
-        self.manifest_entries_after_entry_pruning += 
other.manifest_entries_after_entry_pruning;
-        self.manifest_entries_pruned_by_level += 
other.manifest_entries_pruned_by_level;
-        self.manifest_entries_pruned_by_row_ranges += 
other.manifest_entries_pruned_by_row_ranges;
-        self.manifest_entries_pruned_by_data_stats += 
other.manifest_entries_pruned_by_data_stats;
-        self.manifest_entries_after_manifest_filters +=
-            other.manifest_entries_after_manifest_filters;
-        self.manifest_entries_after_merge += 
other.manifest_entries_after_merge;
-        self.manifest_entries_pruned_by_cross_schema_stats +=
-            other.manifest_entries_pruned_by_cross_schema_stats;
-        self.manifest_entries_after_cross_schema_stats +=
-            other.manifest_entries_after_cross_schema_stats;
-        self.data_evolution_groups_before_stats += 
other.data_evolution_groups_before_stats;
-        self.data_evolution_groups_pruned_by_stats += 
other.data_evolution_groups_pruned_by_stats;
-        self.data_evolution_groups_pruned_by_row_ranges +=
-            other.data_evolution_groups_pruned_by_row_ranges;
-        self.split_candidates_built += other.split_candidates_built;
-        self.splits_before_limit += other.splits_before_limit;
-        self.splits_after_limit += other.splits_after_limit;
-        self.final_splits += other.final_splits;
-        self.final_files += other.final_files;
-        self.limit_early_stopped |= other.limit_early_stopped;
-    }
-
     pub(crate) fn record_manifest_lists(&mut self, base_count: usize, 
delta_count: usize) {
         self.base_manifest_files = base_count;
         self.delta_manifest_files = delta_count;
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index fd7a8d13..7c106fc1 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -553,10 +553,6 @@ impl DataSplit {
         self.snapshot_id
     }
 
-    pub(crate) fn with_snapshot_id(mut self, snapshot_id: i64) -> Self {
-        self.snapshot_id = snapshot_id;
-        self
-    }
     pub fn partition(&self) -> &BinaryRow {
         &self.partition
     }
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 53f8bb63..367e0de1 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -197,7 +197,7 @@ async fn read_manifest_bytes_with_sidecar(
 fn validate_incremental_entries(entries: &[ManifestEntry]) -> 
crate::Result<()> {
     if entries.iter().any(|entry| *entry.kind() != FileKind::Add) {
         return Err(crate::Error::DataInvalid {
-            message: "Incremental delta manifests must contain only ADD 
entries".into(),
+            message: "Incremental manifests must contain only ADD 
entries".into(),
             source: None,
         });
     }
@@ -212,7 +212,7 @@ enum IncrementalSplitMode {
 
 enum ManifestListSource<'a> {
     Snapshot(&'a Snapshot),
-    AppendDeltas(&'a [Snapshot]),
+    IncrementalLists(Vec<String>),
     IncrementalList(&'a str),
 }
 
@@ -255,11 +255,7 @@ async fn read_all_manifest_entries(
             Vec::new(),
             read_manifest_list(file_io, table_path, name).await?,
         ),
-        ManifestListSource::AppendDeltas(snapshots) => {
-            let names = snapshots
-                .iter()
-                .map(|snapshot| snapshot.delta_manifest_list().to_string())
-                .collect::<Vec<_>>();
+        ManifestListSource::IncrementalLists(names) => {
             let delta = futures::stream::iter(names)
                 .map(|name| async move { read_manifest_list(file_io, 
table_path, &name).await })
                 .buffered(manifest_parallelism)
@@ -1450,19 +1446,19 @@ impl<'a> TableScan<'a> {
         }
     }
 
-    pub(crate) async fn plan_snapshot_deltas_with_trace(
+    pub(crate) async fn plan_incremental_lists_with_trace(
         &self,
-        snapshots: &[Snapshot],
         end_snapshot: &Snapshot,
+        manifest_lists: Vec<String>,
         trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Plan> {
         match &self.0 {
             TableScanKind::Paimon(scan) => {
-                scan.plan_snapshot_deltas(snapshots, end_snapshot, trace)
+                scan.plan_incremental_lists(end_snapshot, manifest_lists, 
trace)
                     .await
             }
             TableScanKind::Format(_) => Err(crate::Error::Unsupported {
-                message: "Format tables do not support incremental delta 
scan".to_string(),
+                message: "Format tables do not support incremental batch 
scans".to_string(),
             }),
         }
     }
@@ -2374,12 +2370,13 @@ impl<'a> PaimonTableScan<'a> {
         .await
     }
 
-    /// Plan all selected APPEND deltas together. Entries are merged before
-    /// splitting so versions of a primary key cannot escape into separate 
plans.
-    async fn plan_snapshot_deltas(
+    /// Pack all selected delta or changelog files as one batch, as Java's
+    /// IncrementalDeltaStartingScanner does. Every physical event is retained;
+    /// sharding, split costs and LIMIT apply to the complete selected range.
+    async fn plan_incremental_lists(
         &self,
-        snapshots: &[Snapshot],
         end_snapshot: &Snapshot,
+        manifest_lists: Vec<String>,
         trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Plan> {
         self.validate_read_options()?;
@@ -2388,7 +2385,7 @@ impl<'a> PaimonTableScan<'a> {
         scan.incremental_split_mode = Some(IncrementalSplitMode::Batch);
         scan.plan_snapshot_from_lists(
             end_snapshot,
-            ManifestListSource::AppendDeltas(snapshots),
+            ManifestListSource::IncrementalLists(manifest_lists),
             data_evolution_read_field_ids.as_ref(),
             trace,
         )
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index a03eae62..e2a8e203 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -48,7 +48,7 @@ use crate::table::row_kind_generator::RowKindGenerator;
 use crate::table::write_batch_normalize::normalize_write_array;
 use crate::table::{Snapshot, Table, TableScan};
 use crate::Result;
-use arrow_array::{ArrayRef, Int8Array, RecordBatch};
+use arrow_array::{Array, ArrayRef, Int8Array, RecordBatch};
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
@@ -875,10 +875,10 @@ impl TableWrite {
         let actual_field_count = actual_schema.fields().len();
         // Primary-key data writers consume `_VALUE_KIND` independently of 
whether
         // separate changelog files are enabled. Row-kind generation and
-        // cross-partition routing add the column themselves, so callers must 
not.
-        let allows_value_kind = !self.primary_key_indices.is_empty()
-            && self.row_kind_generator.is_none()
-            && !matches!(self.bucket_assigner, 
BucketAssignerEnum::CrossPartition(_));
+        // cross-partition routing replaces the column after preserving input
+        // kinds and emitting migration DELETEs. A configured generator owns 
it.
+        let allows_value_kind =
+            !self.primary_key_indices.is_empty() && 
self.row_kind_generator.is_none();
         let includes_value_kind = allows_value_kind && actual_field_count == 
table_field_count + 1;
 
         if actual_field_count != table_field_count && !includes_value_kind {
@@ -1060,7 +1060,13 @@ impl TableWrite {
             );
             let (rows, row_kinds) = groups.entry(key).or_default();
             rows.push(row_idx);
-            row_kinds.push(kinds.map_or(RowKind::Insert.to_value(), |kinds| 
kinds.value(row_idx)));
+            row_kinds.push(kinds.map_or(RowKind::Insert.to_value(), |kinds| {
+                if kinds.is_null(row_idx) {
+                    RowKind::Insert.to_value()
+                } else {
+                    kinds.value(row_idx)
+                }
+            }));
         }
         // Generated row kinds are replaced below; migration DELETEs must not
         // append a second _VALUE_KIND column to a rowkind.field batch.
@@ -1121,6 +1127,17 @@ impl TableWrite {
 
     fn generate_rowkind_batch(&self, batch: &RecordBatch) -> 
Result<RecordBatch> {
         let Some(generator) = &self.row_kind_generator else {
+            // Every PK batch sent to a buffered writer uses the same schema.
+            // Mixing default INSERT batches with explicit kinds must neither
+            // discard DELETEs nor concatenate columns by the wrong position.
+            if !self.primary_key_indices.is_empty()
+                && batch.column_by_name(VALUE_KIND_FIELD_NAME).is_none()
+            {
+                return Self::add_per_row_value_kind_column(
+                    batch,
+                    vec![RowKind::Insert.to_value(); batch.num_rows()],
+                );
+            }
             return Ok(batch.clone());
         };
         if batch
@@ -4396,6 +4413,77 @@ pub(in crate::table) mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_pk_writer_mixes_default_and_explicit_row_kinds() {
+        for producer in ["none", "input"] {
+            for row_first in [false, true] {
+                let file_io = test_file_io();
+                let table_path = 
format!("memory:/mixed-kinds-{producer}-{row_first}");
+                setup_dirs(&file_io, &table_path).await;
+                let table = Table::new(
+                    file_io,
+                    Identifier::new("default", "mixed_kinds"),
+                    table_path,
+                    pk_changelog_schema(&[("changelog-producer", producer)]),
+                    None,
+                );
+                let mut writer = TableWrite::new(&table, 
"test-user".to_owned()).unwrap();
+                let first = if row_first {
+                    make_batch_with_value_kind(vec![1], vec![10], vec![0])
+                } else {
+                    make_batch(vec![1], vec![10])
+                };
+                let second = if row_first {
+                    make_batch(vec![1], vec![20])
+                } else {
+                    make_batch_with_value_kind(vec![1], vec![20], vec![3])
+                };
+                writer.write_arrow_batch(&first).await.unwrap();
+                writer.write_arrow_batch(&second).await.unwrap();
+                let messages = writer.prepare_commit().await.unwrap();
+                TableCommit::new(table.clone(), "test-user".to_owned())
+                    .commit(messages)
+                    .await
+                    .unwrap();
+                assert_eq!(
+                    read_id_value_rows(&table).await,
+                    if row_first { vec![(1, 20)] } else { vec![] }
+                );
+                if producer == "input" {
+                    let builder = table.new_read_builder();
+                    let plan = builder
+                        
.new_incremental_scan(super::super::IncrementalScanMode::Changelog, 0, 1)
+                        .plan_combined()
+                        .await
+                        .unwrap();
+                    let batches: Vec<RecordBatch> = 
futures::TryStreamExt::try_collect(
+                        builder
+                            .new_read()
+                            .unwrap()
+                            .to_arrow_with_row_kind(plan.splits())
+                            .unwrap(),
+                    )
+                    .await
+                    .unwrap();
+                    let kinds: Vec<String> = batches
+                        .iter()
+                        .flat_map(|batch| {
+                            batch
+                                .column(0)
+                                .as_any()
+                                .downcast_ref::<StringArray>()
+                                .unwrap()
+                                .iter()
+                                .map(|kind| kind.unwrap().to_owned())
+                                .collect::<Vec<_>>()
+                        })
+                        .collect();
+                    assert_eq!(kinds, vec!["+I", if row_first { "+I" } else { 
"-D" }]);
+                }
+            }
+        }
+    }
+
     #[tokio::test]
     async fn test_default_changelog_producer_accepts_value_kind() {
         let file_io = test_file_io();
@@ -5387,8 +5475,10 @@ pub(in crate::table) mod tests {
     }
 
     #[tokio::test]
-    async fn 
test_input_changelog_cross_partition_write_rejects_caller_value_kind() {
+    async fn 
test_input_changelog_cross_partition_write_preserves_caller_value_kind() {
         let file_io = test_file_io();
+        let table_path = "memory:/test_cross_partition_input_kinds";
+        setup_dirs(&file_io, table_path).await;
         let schema = Schema::builder()
             .column("pt", DataType::VarChar(VarCharType::string_type()))
             .column("id", DataType::Int(IntType::new()))
@@ -5400,32 +5490,92 @@ pub(in crate::table) mod tests {
             .unwrap();
         let table = Table::new(
             file_io,
-            Identifier::new("default", 
"test_cross_partition_schema_validation"),
-            "memory:/test_cross_partition_schema_validation".to_string(),
+            Identifier::new("default", "test_cross_partition_input_kinds"),
+            table_path.to_string(),
             TableSchema::new(0, &schema),
             None,
         );
         let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
 
-        let error = table_write
-            .write_arrow_batch(&make_partitioned_batch_with_value_kind(
-                vec!["a"],
-                vec![1],
-                vec![10],
-                vec![0],
-            ))
+        let batch = make_partitioned_batch_with_value_kind(
+            vec!["a", "b", "b", "b"],
+            vec![1, 1, 1, 1],
+            vec![10, 20, 30, 40],
+            vec![0, 2, 1, 3],
+        );
+        let mut fields = batch.schema().fields().to_vec();
+        fields[3] = Arc::new(ArrowField::new(
+            VALUE_KIND_FIELD_NAME,
+            ArrowDataType::Int8,
+            true,
+        ));
+        let mut columns = batch.columns().to_vec();
+        // The null slot deliberately carries a DELETE byte in its underlying
+        // buffer. Cross-partition routing must use INSERT for the logical 
null.
+        columns[3] = Arc::new(Int8Array::new(
+            vec![3, 2, 1, 3].into(),
+            Some(arrow_buffer::NullBuffer::from(vec![
+                false, true, true, true,
+            ])),
+        ));
+        let batch = RecordBatch::try_new(Arc::new(ArrowSchema::new(fields)), 
columns).unwrap();
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
             .await
-            .unwrap_err();
+            .unwrap();
 
-        assert_data_invalid_contains(
-            error,
-            &[
-                "field count",
-                "expected 3",
-                "actual 4",
-                "<no field>",
-                VALUE_KIND_FIELD_NAME,
-            ],
+        let builder = table.new_read_builder();
+        let plan = builder
+            
.new_incremental_scan(super::super::IncrementalScanMode::Changelog, 0, 1)
+            .plan_combined()
+            .await
+            .unwrap();
+        let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(
+            builder
+                .new_read()
+                .unwrap()
+                .to_arrow_with_row_kind(plan.splits())
+                .unwrap(),
+        )
+        .await
+        .unwrap();
+        let mut events = Vec::new();
+        for batch in batches {
+            let kinds = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let partitions = batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let values = batch
+                .column(3)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            events.extend((0..batch.num_rows()).map(|i| {
+                (
+                    kinds.value(i).to_owned(),
+                    partitions.value(i).to_owned(),
+                    values.value(i),
+                )
+            }));
+        }
+        events.sort_unstable();
+        assert_eq!(
+            events,
+            vec![
+                ("+I".to_owned(), "a".to_owned(), 10),
+                ("+U".to_owned(), "b".to_owned(), 20),
+                ("-D".to_owned(), "a".to_owned(), 20),
+                ("-D".to_owned(), "b".to_owned(), 40),
+                ("-U".to_owned(), "b".to_owned(), 30),
+            ]
         );
     }
 
diff --git a/crates/paimon/tests/incremental_changelog_batch_test.rs 
b/crates/paimon/tests/incremental_changelog_batch_test.rs
new file mode 100644
index 00000000..da652283
--- /dev/null
+++ b/crates/paimon/tests/incremental_changelog_batch_test.rs
@@ -0,0 +1,443 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{Int32Array, RecordBatch, StringArray};
+use futures::TryStreamExt;
+use paimon::spec::{Datum, PredicateBuilder};
+use paimon::table::{IncrementalScanMode, Plan, Table};
+
+use common::incremental_helpers::{
+    make_batch, make_batch_with_kinds, memory_table, persist_table_schema, 
pk_schema, setup_dirs,
+    write_batch,
+};
+
+async fn table(name: &str, bucket: &str, dv: bool, split_size: &str) -> Table {
+    let path = format!("memory:/changelog-batch/{name}");
+    let (file_io, table) = memory_table(
+        &path,
+        pk_schema(&[
+            ("changelog-producer", "input"),
+            ("bucket", bucket),
+            (
+                "deletion-vectors.enabled",
+                if dv { "true" } else { "false" },
+            ),
+            ("source.split.target-size", split_size),
+            ("source.split.open-file-cost", "1b"),
+        ]),
+    );
+    setup_dirs(&file_io, &path).await;
+    persist_table_schema(&file_io, &path, table.schema()).await;
+    table
+}
+
+async fn events(table: &Table, plan: &Plan) -> Vec<(String, i32, i32)> {
+    let batches: Vec<RecordBatch> = table
+        .new_read_builder()
+        .new_read()
+        .unwrap()
+        .to_arrow_with_row_kind(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut rows = Vec::new();
+    for batch in batches {
+        let kinds = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        let ids = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let values = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        rows.extend((0..batch.num_rows()).map(|row| {
+            (
+                kinds.value(row).to_owned(),
+                ids.value(row),
+                values.value(row),
+            )
+        }));
+    }
+    rows.sort_unstable();
+    rows
+}
+
+#[tokio::test]
+async fn combined_changelog_packs_overlapping_versions_across_snapshots() {
+    let table = table("overlapping", "1", false, "1mb").await;
+    for (value, kind) in [(10, 0), (11, 2), (12, 1), (13, 3)] {
+        write_batch(
+            &table,
+            &make_batch_with_kinds(vec![1], vec![value], vec![kind]),
+        )
+        .await;
+    }
+    let builder = table.new_read_builder();
+    let per_snapshot = builder
+        .new_incremental_scan(IncrementalScanMode::Changelog, 0, 4)
+        .plan()
+        .await
+        .unwrap();
+    assert_eq!(per_snapshot.splits().len(), 4);
+    for mode in [IncrementalScanMode::Changelog, IncrementalScanMode::Auto] {
+        let plan = builder
+            .new_incremental_scan(mode, 0, 4)
+            .plan_combined()
+            .await
+            .unwrap();
+        assert_eq!(plan.snapshot_id(), Some(4));
+        assert_eq!(plan.splits().len(), 1, "batch packing must span 
snapshots");
+        let split = &plan.splits()[0];
+        assert_eq!(split.snapshot_id(), 4);
+        assert!(split.is_streaming());
+        assert_eq!(split.data_files().len(), 4);
+        assert!(split
+            .data_files()
+            .iter()
+            .all(|file| file.file_name.starts_with("changelog-")));
+        assert_eq!(
+            events(&table, &plan).await,
+            vec![
+                ("+I".into(), 1, 10),
+                ("+U".into(), 1, 11),
+                ("-D".into(), 1, 13),
+                ("-U".into(), 1, 12),
+            ]
+        );
+    }
+}
+
+#[tokio::test]
+async fn combined_changelog_shards_cover_every_physical_event_once() {
+    for dv in [false, true] {
+        let table = table(&format!("shards-{dv}"), "4", dv, "1mb").await;
+        for version in 0..3 {
+            write_batch(&table, &make_batch((0..32).collect(), vec![version; 
32])).await;
+        }
+        let builder = table.new_read_builder();
+        let full = builder
+            .new_incremental_scan(IncrementalScanMode::Changelog, 0, 3)
+            .plan_combined()
+            .await
+            .unwrap();
+        let expected = events(&table, &full).await;
+        assert_eq!(expected.len(), 96);
+        for mode in [IncrementalScanMode::Changelog, 
IncrementalScanMode::Auto] {
+            for count in [2, 5] {
+                let mut union = Vec::new();
+                let mut file_names = std::collections::HashSet::new();
+                for index in 0..count {
+                    let plan = builder
+                        .new_incremental_scan(mode, 0, 3)
+                        .with_shard(index, count)
+                        .unwrap()
+                        .plan_combined()
+                        .await
+                        .unwrap();
+                    assert_eq!(plan.snapshot_id(), Some(3));
+                    for split in plan.splits() {
+                        assert!(split.is_streaming());
+                        assert_eq!(split.snapshot_id(), 3);
+                        if !dv {
+                            assert_eq!(split.bucket() as usize % count, index);
+                        }
+                        for file in split.data_files() {
+                            assert!(
+                                file_names.insert(file.file_name.clone()),
+                                "a file belongs to exactly one shard"
+                            );
+                        }
+                    }
+                    union.extend(events(&table, &plan).await);
+                }
+                union.sort_unstable();
+                assert_eq!(union, expected, "dv={dv}, mode={mode:?}, 
count={count}");
+                assert_eq!(
+                    file_names.len(),
+                    full.splits()
+                        .iter()
+                        .map(|split| split.data_files().len())
+                        .sum::<usize>()
+                );
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn combined_changelog_applies_limit_once_to_the_whole_batch() {
+    let table = table("limit", "1", false, "1b").await;
+    for id in 0..4 {
+        write_batch(&table, &make_batch(vec![id], vec![id * 10])).await;
+    }
+    for limit in [0, 1, 2, 5] {
+        let mut builder = table.new_read_builder();
+        builder.with_limit(limit);
+        let plan = builder
+            .new_incremental_scan(IncrementalScanMode::Changelog, 0, 4)
+            .plan_combined()
+            .await
+            .unwrap();
+        assert_eq!(plan.snapshot_id(), Some(4));
+        assert_eq!(plan.splits().len(), limit.min(4));
+        assert_eq!(events(&table, &plan).await.len(), limit.min(4));
+    }
+    // Value predicates remain residual filters over physical events; early
+    // LIMIT must not discard a later matching event.
+    let predicate = PredicateBuilder::new(table.schema().fields())
+        .greater_than("value", Datum::Int(15))
+        .unwrap();
+    let mut builder = table.new_read_builder();
+    builder.with_filter(predicate).with_limit(1);
+    let plan = builder
+        .new_incremental_scan(IncrementalScanMode::Auto, 0, 4)
+        .plan_combined()
+        .await
+        .unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+    let value = batches[0]
+        .column(1)
+        .as_any()
+        .downcast_ref::<Int32Array>()
+        .unwrap()
+        .value(0);
+    assert!(value > 15);
+}
+
+async fn patch_snapshot(table: &Table, id: i64, key: &str, value: 
serde_json::Value) {
+    let path = table.snapshot_manager().snapshot_path(id);
+    let bytes = table
+        .file_io()
+        .new_input(&path)
+        .unwrap()
+        .read()
+        .await
+        .unwrap();
+    let mut snapshot: serde_json::Value = 
serde_json::from_slice(&bytes).unwrap();
+    snapshot[key] = value;
+    table
+        .file_io()
+        .new_output(&path)
+        .unwrap()
+        .write(serde_json::to_vec(&snapshot).unwrap().into())
+        .await
+        .unwrap();
+}
+
+#[tokio::test]
+async fn 
combined_changelog_selects_compact_events_but_skips_overwrite_and_missing_lists()
 {
+    let table = table("commit-selection", "1", false, "1mb").await;
+    for id in 1..=4 {
+        write_batch(&table, &make_batch(vec![id], vec![id * 10])).await;
+    }
+    patch_snapshot(&table, 2, "commitKind", "COMPACT".into()).await;
+    patch_snapshot(&table, 3, "commitKind", "OVERWRITE".into()).await;
+    patch_snapshot(&table, 4, "changelogManifestList", 
serde_json::Value::Null).await;
+    let builder = table.new_read_builder();
+    for mode in [IncrementalScanMode::Changelog, IncrementalScanMode::Auto] {
+        let plan = builder
+            .new_incremental_scan(mode, 0, 4)
+            .plan_combined()
+            .await
+            .unwrap();
+        assert_eq!(plan.snapshot_id(), Some(4));
+        assert!(plan.splits().iter().all(|split| split.snapshot_id() == 4));
+        assert_eq!(
+            events(&table, &plan).await,
+            vec![("+I".into(), 1, 10), ("+I".into(), 2, 20)]
+        );
+        let last = builder
+            .new_incremental_scan(mode, 2, 4)
+            .plan_combined()
+            .await
+            .unwrap();
+        assert_eq!(last.snapshot_id(), Some(4));
+        assert!(last.splits().is_empty());
+    }
+    let per_snapshot = builder
+        .new_incremental_scan(IncrementalScanMode::Changelog, 0, 4)
+        .plan()
+        .await
+        .unwrap();
+    let ids: Vec<_> = per_snapshot
+        .splits()
+        .iter()
+        .map(|split| match split {
+            paimon::table::IncrementalSplit::Data(split) => 
split.snapshot_id(),
+            _ => panic!("changelog must not contain diff pairs"),
+        })
+        .collect();
+    assert_eq!(ids, vec![1, 2]);
+}
+
+#[tokio::test]
+async fn combined_changelog_preserves_empty_boundaries_with_shards() {
+    let table = table("empty-boundaries", "1", false, "1mb").await;
+    for id in 1..=3 {
+        write_batch(&table, &make_batch(vec![id], vec![id * 10])).await;
+    }
+    let builder = table.new_read_builder();
+    for mode in [IncrementalScanMode::Changelog, IncrementalScanMode::Auto] {
+        for id in [0, 1, 3] {
+            let plan = builder
+                .new_incremental_scan(mode, id, id)
+                .with_shard(1, 3)
+                .unwrap()
+                .plan_combined()
+                .await
+                .unwrap();
+            assert_eq!(plan.snapshot_id(), Some(id));
+            assert!(plan.splits().is_empty());
+        }
+        for (start, end) in [(-1, 0), (4, 4), (3, 2)] {
+            assert!(matches!(
+                builder
+                    .new_incremental_scan(mode, start, end)
+                    .with_shard(1, 3)
+                    .unwrap()
+                    .plan_combined()
+                    .await,
+                Err(paimon::Error::DataInvalid { .. })
+            ));
+        }
+    }
+    // Delta retains its existing requirement for an actual ending snapshot.
+    assert!(builder
+        .new_incremental_scan(IncrementalScanMode::Delta, 0, 0)
+        .plan_combined_delta()
+        .await
+        .is_err());
+}
+
+#[tokio::test]
+async fn 
combined_changelog_sharding_keeps_residual_filters_before_reader_limit() {
+    let table = table("sharded-residual", "4", false, "1b").await;
+    for value in 0..3 {
+        write_batch(&table, &make_batch((0..32).collect(), vec![value; 
32])).await;
+    }
+    let predicate = PredicateBuilder::new(table.schema().fields())
+        .greater_than("value", Datum::Int(0))
+        .unwrap();
+    let mut read = table.new_read_builder();
+    read.with_filter(predicate);
+    read.with_projection(&["value", "id"]).unwrap();
+    let mut all_candidates = 0;
+    for index in 0..5 {
+        let plan = read
+            .new_incremental_scan(IncrementalScanMode::Changelog, 0, 3)
+            .with_shard(index, 5)
+            .unwrap()
+            .plan_combined()
+            .await
+            .unwrap();
+        let batches: Vec<RecordBatch> = read
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let candidates = 
batches.iter().map(RecordBatch::num_rows).sum::<usize>();
+        all_candidates += candidates;
+        let mut limited = read.clone();
+        limited.with_limit(1);
+        let plan = limited
+            .new_incremental_scan(IncrementalScanMode::Changelog, 0, 3)
+            .with_shard(index, 5)
+            .unwrap()
+            .plan_combined()
+            .await
+            .unwrap();
+        let batches: Vec<RecordBatch> = limited
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+            candidates.min(1)
+        );
+        for batch in batches {
+            assert_eq!(batch.schema().fields()[0].name(), "value");
+            assert_eq!(batch.schema().fields()[1].name(), "id");
+            let values = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            assert!((0..batch.num_rows()).all(|row| values.value(row) > 0));
+        }
+    }
+    assert_eq!(all_candidates, 64);
+}
+
+#[tokio::test]
+async fn timestamp_changelog_trace_describes_one_packed_batch() {
+    let table = table("trace", "1", false, "1mb").await;
+    for id in 1..=3 {
+        write_batch(&table, &make_batch(vec![1], vec![id])).await;
+        patch_snapshot(&table, i64::from(id), "timeMillis", (id * 
100).into()).await;
+    }
+    let table = table.copy_with_options(std::collections::HashMap::from([
+        ("scan.mode".into(), "incremental".into()),
+        ("incremental-between-timestamp".into(), "0,300".into()),
+    ]));
+    let (plan, trace) = table
+        .new_read_builder()
+        .new_scan()
+        .plan_with_trace()
+        .await
+        .unwrap();
+    assert_eq!(plan.snapshot_id(), Some(3));
+    assert_eq!(trace.snapshot_id, Some(3));
+    assert_eq!(trace.base_manifest_files, 0);
+    assert_eq!(trace.delta_manifest_files, 3);
+    assert_eq!(trace.manifest_entries_read, 3);
+    assert_eq!(trace.final_splits, 1);
+    assert_eq!(trace.final_files, 3);
+    assert_eq!(
+        trace.planned_data_file_bytes,
+        plan.splits()
+            .iter()
+            .flat_map(|split| split.data_files())
+            .map(|file| file.file_size as u64)
+            .sum::<u64>()
+    );
+    assert_eq!(events(&table, &plan).await.len(), 3);
+}
diff --git a/crates/paimon/tests/writer_resources_test.rs 
b/crates/paimon/tests/writer_resources_test.rs
index 02bd6711..264ece12 100644
--- a/crates/paimon/tests/writer_resources_test.rs
+++ b/crates/paimon/tests/writer_resources_test.rs
@@ -17,11 +17,13 @@
 
 mod common;
 
+use arrow_array::{Array, Int8Array, RecordBatch};
 use common::incremental_helpers::{
-    make_batch, make_partitioned_batch, memory_table, partitioned_pk_schema, 
pk_schema, setup_dirs,
+    make_batch, make_batch_with_kinds, make_partitioned_batch, memory_table, 
partitioned_pk_schema,
+    pk_schema, setup_dirs,
 };
 use paimon::resource::{MemoryPool, ResourceContext};
-use paimon::spec::{DataType, IntType, Schema, TableSchema};
+use paimon::spec::{DataType, IntType, RowKind, Schema, TableSchema, 
VALUE_KIND_FIELD_NAME};
 use paimon::Error;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
@@ -51,17 +53,34 @@ async fn assert_no_data_files(io: &paimon::io::FileIO, 
path: &str) {
     );
 }
 
+fn buffered_pk_bytes(batch: &RecordBatch) -> usize {
+    let bytes = batch
+        .columns()
+        .iter()
+        .map(|column| column.get_buffer_memory_size())
+        .sum::<usize>();
+    // PK input normalization adds INSERT kinds when callers omit the column.
+    if batch.column_by_name(VALUE_KIND_FIELD_NAME).is_none() {
+        bytes
+            + Int8Array::from(vec![RowKind::Insert.to_value(); 
batch.num_rows()])
+                .get_buffer_memory_size()
+    } else {
+        bytes
+    }
+}
+
 #[tokio::test]
 async fn key_value_buffer_shares_limit_and_releases_on_failure() {
+    assert_key_value_buffer_limit(make_batch(vec![1, 2], vec![10, 20])).await;
+    assert_key_value_buffer_limit(make_batch_with_kinds(vec![1, 2], vec![10, 
20], vec![0, 2]))
+        .await;
+}
+
+async fn assert_key_value_buffer_limit(batch: RecordBatch) {
     let path = "memory:/writer_resources_pk";
     let (io, table) = memory_table(path, pk_schema(&[("file.format", 
"parquet")]));
     setup_dirs(&io, path).await;
-    let batch = make_batch(vec![1, 2], vec![10, 20]);
-    let bytes: usize = batch
-        .columns()
-        .iter()
-        .map(|column| column.get_buffer_memory_size())
-        .sum();
+    let bytes = buffered_pk_bytes(&batch);
     let resources = ResourceContext::builder()
         .memory_limit(bytes)
         .build()
@@ -273,11 +292,7 @@ async fn 
key_value_flush_rejection_prevents_partial_commit() {
     );
     setup_dirs(&io, path).await;
     let batch = make_batch(vec![1, 2], vec![10, 20]);
-    let bytes: usize = batch
-        .columns()
-        .iter()
-        .map(|column| column.get_buffer_memory_size())
-        .sum();
+    let bytes = buffered_pk_bytes(&batch);
     let resources = ResourceContext::builder()
         .memory_limit(bytes)
         .build()
@@ -291,6 +306,8 @@ async fn 
key_value_flush_rejection_prevents_partial_commit() {
         write.write_arrow_batch(&batch).await,
         Err(Error::ResourceExhausted { .. })
     ));
+    // The batch must be admitted before its triggered flush exhausts the pool.
+    assert_eq!(resources.metrics().peak_reserved_memory_bytes, bytes);
     assert!(write.prepare_commit().await.is_err());
     assert_eq!(resources.metrics().reserved_memory_bytes, 0);
     assert_no_data_files(&io, path).await;
@@ -302,11 +319,7 @@ async fn 
key_value_prepare_rejection_cleans_uncommitted_file() {
     let (io, table) = memory_table(path, pk_schema(&[("file.format", 
"parquet")]));
     setup_dirs(&io, path).await;
     let batch = make_batch(vec![1, 2], vec![10, 20]);
-    let bytes: usize = batch
-        .columns()
-        .iter()
-        .map(|column| column.get_buffer_memory_size())
-        .sum();
+    let bytes = buffered_pk_bytes(&batch);
     let resources = ResourceContext::builder()
         .memory_limit(bytes)
         .build()

Reply via email to