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 81d2986d perf(datafusion): honor Paimon read.batch-size (#547)
81d2986d is described below

commit 81d2986d2bdfb6524ecfd38a233b1ef564a9389b
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jul 19 22:49:18 2026 +0800

    perf(datafusion): honor Paimon read.batch-size (#547)
---
 .../datafusion/src/physical_plan/scan.rs           |  69 ++++++++++++
 crates/integrations/datafusion/src/sql_context.rs  |  58 ++++++++++
 crates/paimon/src/spec/core_options.rs             |  42 +++++++
 crates/paimon/src/spec/schema.rs                   |  53 ++++++++-
 crates/paimon/src/table/data_evolution_reader.rs   | 125 ++++++++++++++++++++-
 crates/paimon/src/table/data_file_reader.rs        |  10 +-
 crates/paimon/src/table/format_read_builder.rs     |   5 +-
 crates/paimon/src/table/format_table_read.rs       |   7 +-
 crates/paimon/src/table/kv_file_reader.rs          |  97 +++++++++++++++-
 crates/paimon/src/table/read_builder.rs            |   3 +-
 crates/paimon/src/table/table_read.rs              |  18 +--
 11 files changed, 470 insertions(+), 17 deletions(-)

diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index d31891ed..25709034 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -486,4 +486,73 @@ mod tests {
 
         assert_eq!(actual_ids, vec![2, 3, 4]);
     }
+
+    #[tokio::test]
+    async fn test_execute_uses_read_batch_size_option() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+
+        write_int_parquet_file(
+            &bucket_dir.join("data.parquet"),
+            vec![("id", vec![1, 2, 3, 4, 5])],
+            None,
+        );
+        let file_size = 
fs::metadata(bucket_dir.join("data.parquet")).unwrap().len() as i64;
+
+        let file_io = FileIOBuilder::new("file").build().unwrap();
+        let table_schema = TableSchema::new(
+            0,
+            &PaimonSchema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .option("read.batch-size", "2")
+                .build()
+                .unwrap(),
+        );
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "t"),
+            table_path,
+            table_schema,
+            None,
+        );
+        let split = paimon::DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![test_data_file("data.parquet", 5, 
file_size)])
+            .build()
+            .unwrap();
+        let scan = PaimonTableScan::new(
+            test_schema(),
+            table,
+            test_read_type(),
+            None,
+            vec![Arc::from(vec![split])],
+            None,
+            false,
+            None,
+            None,
+            true,
+        );
+
+        let ctx = SessionContext::new();
+        let batches = scan
+            .execute(0, ctx.task_ctx())
+            .expect("execute should succeed")
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(
+            batches
+                .iter()
+                .map(RecordBatch::num_rows)
+                .collect::<Vec<_>>(),
+            vec![2, 2, 1]
+        );
+    }
 }
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index dedd7ebc..3190487b 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -6626,6 +6626,64 @@ mod tests {
         (temp_dir, sql_context)
     }
 
+    #[tokio::test]
+    async fn test_dynamic_read_batch_size_overrides_table_option() {
+        let (_tmp, sql_context) = setup_fs_sql_context().await;
+
+        sql_context
+            .sql(
+                "CREATE TABLE paimon.test_db.batch_size_t (id INT) \
+                 WITH ('read.batch-size' = '3')",
+            )
+            .await
+            .unwrap();
+        sql_context
+            .sql("INSERT INTO paimon.test_db.batch_size_t VALUES (1), (2), 
(3), (4), (5)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        sql_context
+            .sql("SET 'paimon.read.batch-size' = '2'")
+            .await
+            .unwrap();
+        let batches = sql_context
+            .sql("SELECT id FROM paimon.test_db.batch_size_t")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            batches
+                .iter()
+                .map(|batch| batch.num_rows())
+                .collect::<Vec<_>>(),
+            vec![2, 2, 1]
+        );
+
+        sql_context
+            .sql("RESET 'paimon.read.batch-size'")
+            .await
+            .unwrap();
+        let batches = sql_context
+            .sql("SELECT id FROM paimon.test_db.batch_size_t")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            batches
+                .iter()
+                .map(|batch| batch.num_rows())
+                .collect::<Vec<_>>(),
+            vec![3, 2]
+        );
+    }
+
     #[tokio::test]
     async fn test_truncate_table() {
         let (_tmp, sql_context) = setup_fs_sql_context().await;
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index d1865191..e5a61b54 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -73,6 +73,7 @@ const MANIFEST_TARGET_FILE_SIZE_OPTION: &str = 
"manifest.target-file-size";
 const MANIFEST_TARGET_SIZE_OPTION: &str = "manifest.target-size";
 const MANIFEST_MERGE_MIN_COUNT_OPTION: &str = "manifest.merge-min-count";
 const WRITE_PARQUET_BUFFER_SIZE_OPTION: &str = "write.parquet-buffer-size";
+const READ_BATCH_SIZE_OPTION: &str = "read.batch-size";
 pub(crate) const SEQUENCE_FIELD_OPTION: &str = "sequence.field";
 pub(crate) const DISABLE_EXPLICIT_TYPE_CASTING_OPTION: &str = 
"disable-explicit-type-casting";
 pub(crate) const DISABLE_ALTER_COLUMN_NULL_TO_NOT_NULL_OPTION: &str =
@@ -109,6 +110,7 @@ const DEFAULT_PARTITION_DEFAULT_NAME: &str = 
"__DEFAULT_PARTITION__";
 const DEFAULT_CHANGELOG_FILE_PREFIX: &str = "changelog-";
 const DEFAULT_TARGET_FILE_SIZE: i64 = 256 * 1024 * 1024;
 const DEFAULT_WRITE_PARQUET_BUFFER_SIZE: i64 = 256 * 1024 * 1024;
+const DEFAULT_READ_BATCH_SIZE: usize = 1024;
 const DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION: &str = 
"dynamic-bucket.target-row-num";
 const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
 const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
@@ -305,6 +307,32 @@ impl<'a> CoreOptions<'a> {
         Self { options }
     }
 
+    /// Preferred number of rows emitted by file-format readers.
+    ///
+    /// Mirrors Java Paimon's `CoreOptions.READ_BATCH_SIZE`.
+    pub fn read_batch_size(&self) -> crate::Result<usize> {
+        let Some(raw) = self.options.get(READ_BATCH_SIZE_OPTION) else {
+            return Ok(DEFAULT_READ_BATCH_SIZE);
+        };
+        let value = raw
+            .parse::<i32>()
+            .map_err(|error| crate::Error::DataInvalid {
+                message: format!(
+                    "Option '{READ_BATCH_SIZE_OPTION}' must be a positive 
integer, got: {raw}"
+                ),
+                source: Some(Box::new(error)),
+            })?;
+        if value <= 0 {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Option '{READ_BATCH_SIZE_OPTION}' must be greater than 0, 
got: {value}"
+                ),
+                source: None,
+            });
+        }
+        Ok(value as usize)
+    }
+
     /// Reject scan options whose semantics the Rust core does not yet 
implement.
     ///
     /// These are not malformed input — they are unimplemented scan modes — so
@@ -1182,6 +1210,20 @@ fn parse_memory_size(value: &str) -> Option<i64> {
 mod tests {
     use super::*;
 
+    #[test]
+    fn test_read_batch_size() {
+        let options = HashMap::new();
+        assert_eq!(CoreOptions::new(&options).read_batch_size().unwrap(), 
1024);
+
+        let options = HashMap::from([("read.batch-size".to_string(), 
"8192".to_string())]);
+        assert_eq!(CoreOptions::new(&options).read_batch_size().unwrap(), 
8192);
+
+        for value in ["0", "-1", "invalid"] {
+            let options = HashMap::from([("read.batch-size".to_string(), 
value.to_string())]);
+            assert!(CoreOptions::new(&options).read_batch_size().is_err());
+        }
+    }
+
     #[test]
     fn test_source_split_defaults() {
         let options = HashMap::new();
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index f92e5ef8..413c0d99 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -135,11 +135,16 @@ impl TableSchema {
         &self.options
     }
 
+    /// Typed view over this schema's table options.
+    pub fn core_options(&self) -> CoreOptions<'_> {
+        CoreOptions::new(&self.options)
+    }
+
     /// Create a copy of this schema with extra options merged in.
     ///
     /// A stored `query-auth.enabled = true` can't be turned off by a dynamic 
override.
     pub fn copy_with_options(&self, mut extra: HashMap<String, String>) -> 
Self {
-        if CoreOptions::new(&self.options).query_auth_enabled() {
+        if self.core_options().query_auth_enabled() {
             extra.insert(QUERY_AUTH_ENABLED_OPTION.to_string(), 
"true".to_string());
         }
         let mut new_schema = self.clone();
@@ -475,6 +480,7 @@ impl TableSchema {
             &new_schema.primary_keys,
             &new_schema.fields,
         )?;
+        Schema::validate_read_batch_size(&new_schema.options)?;
         Ok(new_schema)
     }
 
@@ -921,6 +927,7 @@ impl Schema {
         AggregationConfig::new(&options).validate_create_mode(&primary_keys, 
&fields)?;
         Self::validate_first_row_changelog_producer(&options)?;
         Self::validate_rowkind_field(&options, &primary_keys, &fields)?;
+        Self::validate_read_batch_size(&options)?;
 
         Ok(Self {
             fields,
@@ -1344,6 +1351,13 @@ impl Schema {
         }
     }
 
+    fn validate_read_batch_size(options: &HashMap<String, String>) -> 
crate::Result<()> {
+        CoreOptions::new(options)
+            .read_batch_size()
+            .map(|_| ())
+            .map_err(Self::options_error_to_config_invalid)
+    }
+
     /// Returns top-level Blob field names for create-time Blob contract 
checks.
     fn top_level_blob_field_names(fields: &[DataField]) -> Vec<&str> {
         fields
@@ -1670,6 +1684,43 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_schema_rejects_invalid_read_batch_size() {
+        for value in ["0", "-1", "invalid"] {
+            let err = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .option("read.batch-size", value)
+                .build()
+                .unwrap_err();
+            assert!(
+                matches!(err, crate::Error::ConfigInvalid { ref message }
+                    if message.contains("read.batch-size")),
+                "got {err:?} for read.batch-size={value}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_apply_changes_rejects_invalid_read_batch_size() {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .build()
+            .unwrap();
+        let table_schema = TableSchema::new(0, &schema);
+
+        let err = table_schema
+            .apply_changes(vec![crate::spec::SchemaChange::set_option(
+                "read.batch-size".to_string(),
+                "0".to_string(),
+            )])
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("read.batch-size")),
+            "got {err:?}"
+        );
+    }
+
     #[test]
     fn test_copy_with_replaced_options() {
         let schema = Schema::builder()
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 1d5a1306..e166fea8 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -112,6 +112,7 @@ pub(crate) struct DataEvolutionReader {
     blob_view_resolve_enabled: bool,
     blob_view_rest_env: Option<RESTEnv>,
     blob_read_limiter: BlobReadLimiter,
+    batch_size: Option<usize>,
 }
 
 impl DataEvolutionReader {
@@ -172,9 +173,15 @@ impl DataEvolutionReader {
             blob_view_resolve_enabled,
             blob_view_rest_env,
             blob_read_limiter: BlobReadLimiter::new(),
+            batch_size: None,
         })
     }
 
+    pub(crate) fn with_batch_size(mut self, batch_size: Option<usize>) -> Self 
{
+        self.batch_size = batch_size;
+        self
+    }
+
     /// Read data files in data evolution mode.
     pub fn read(self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
         let splits: Vec<DataSplit> = data_splits.to_vec();
@@ -198,7 +205,8 @@ impl DataEvolutionReader {
                 self.table_fields.clone(),
                 self.wide_file_read_type.clone(),
                 Vec::new(),
-            );
+            )
+            .with_batch_size(self.batch_size);
 
             for split in splits {
                 let row_ranges = split.row_ranges().map(|r| r.to_vec());
@@ -495,7 +503,8 @@ impl DataEvolutionReader {
             HashSet::new(),
             false,
             None,
-        )?;
+        )?
+        .with_batch_size(self.batch_size);
         let mut stream = prescan.read(splits)?;
         let mut view_structs = HashSet::new();
         while let Some(batch) = stream.next().await {
@@ -557,6 +566,7 @@ impl DataEvolutionReader {
         let table_fields = self.table_fields.clone();
         let blob_descriptor_fields = self.blob_descriptor_fields.clone();
         let blob_as_descriptor = self.blob_as_descriptor;
+        let batch_size = self.batch_size;
         let anchor_deletion_vector = anchor_deletion_vector.clone();
         // Batch size for column-merge output. Matches the default Parquet 
reader batch size.
         const MERGE_BATCH_SIZE: usize = 1024;
@@ -623,6 +633,7 @@ impl DataEvolutionReader {
                             schema_manager.clone(),
                             table_schema_id,
                             table_fields.clone(),
+                            batch_size,
                             blob_as_descriptor,
                             anchor_deletion_vector.as_ref(),
                         )
@@ -1109,6 +1120,7 @@ fn open_source_stream(
     schema_manager: SchemaManager,
     table_schema_id: i64,
     table_fields: Vec<DataField>,
+    batch_size: Option<usize>,
     blob_as_descriptor: bool,
     anchor_deletion_vector: Option<&DeletionVectorContext>,
 ) -> crate::Result<ArrowRecordBatchStream> {
@@ -1135,6 +1147,7 @@ fn open_source_stream(
         source.read_fields().to_vec(),
         Vec::new(),
     )
+    .with_batch_size(batch_size)
     .with_blob_as_descriptor(blob_as_descriptor);
 
     match source {
@@ -5075,6 +5088,114 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn 
test_evolution_input_decode_honors_read_batch_size_on_all_file_paths() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+
+        let raw_path = bucket_dir.join("raw.parquet");
+        write_int_parquet_file(
+            &raw_path,
+            vec![
+                ("id", vec![1, 2, 3, 4, 5]),
+                ("value", vec![10, 20, 30, 40, 50]),
+            ],
+            None,
+        );
+        let id_path = bucket_dir.join("id.parquet");
+        write_int_parquet_file(&id_path, vec![("id", vec![6, 7, 8, 9, 10])], 
None);
+        let value_path = bucket_dir.join("value.parquet");
+        write_int_parquet_file(
+            &value_path,
+            vec![("value", vec![60, 70, 80, 90, 100])],
+            None,
+        );
+
+        let file_io = FileIOBuilder::new("file").build().unwrap();
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("value", DataType::Int(IntType::new()))
+                .option("data-evolution.enabled", "true")
+                .option("read.batch-size", "2")
+                .build()
+                .unwrap(),
+        );
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "evolution_batch_size_t"),
+            table_path,
+            table_schema,
+            None,
+        );
+
+        let raw_split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file_meta_with_path(
+                "raw.parquet",
+                0,
+                5,
+                1,
+                raw_path.metadata().unwrap().len() as i64,
+                Some(vec!["id", "value"]),
+            )])
+            .build()
+            .unwrap();
+        let merge_split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![
+                data_file_meta_with_path(
+                    "id.parquet",
+                    5,
+                    5,
+                    1,
+                    id_path.metadata().unwrap().len() as i64,
+                    Some(vec!["id"]),
+                ),
+                data_file_meta_with_path(
+                    "value.parquet",
+                    5,
+                    5,
+                    2,
+                    value_path.metadata().unwrap().len() as i64,
+                    Some(vec!["value"]),
+                ),
+            ])
+            .build()
+            .unwrap();
+
+        let read = TableRead::new(&table, table.schema().fields().to_vec(), 
Vec::new());
+        let batches = read
+            .to_arrow(&[raw_split, merge_split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(
+            batches
+                .iter()
+                .map(RecordBatch::num_rows)
+                .collect::<Vec<_>>(),
+            vec![2, 2, 1, 2, 2, 1]
+        );
+        assert_eq!(
+            collect_int_values(&batches, "id"),
+            vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+        );
+    }
+
     /// _ROW_ID + predicate, raw branch: surviving rows keep their ORIGINAL row
     /// ids (ids are attached before the residual filter), not renumbered ones.
     #[tokio::test]
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 771f69bc..f5b2647e 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -44,6 +44,7 @@ pub(crate) struct DataFileReader {
     read_type: Vec<DataField>,
     predicates: Vec<Predicate>,
     blob_as_descriptor: bool,
+    batch_size: Option<usize>,
 }
 
 impl DataFileReader {
@@ -63,6 +64,7 @@ impl DataFileReader {
             read_type,
             predicates,
             blob_as_descriptor: false,
+            batch_size: None,
         }
     }
 
@@ -71,6 +73,11 @@ impl DataFileReader {
         self
     }
 
+    pub(crate) fn with_batch_size(mut self, batch_size: Option<usize>) -> Self 
{
+        self.batch_size = batch_size;
+        self
+    }
+
     /// Return a copy with a replaced read-type. Used by 
`pk_vector_position_read`
     /// to inject the internal `_ROW_ID` column for physical-position recovery.
     pub(super) fn with_read_type(mut self, read_type: Vec<DataField>) -> Self {
@@ -247,6 +254,7 @@ impl DataFileReader {
         let file_io = self.file_io.clone();
         let split = split.clone();
         let blob_as_descriptor = self.blob_as_descriptor;
+        let batch_size = self.batch_size;
 
         let target_schema = build_target_arrow_schema(&read_type)?;
         let file_fields = data_fields.clone().unwrap_or_else(|| 
table_fields.clone());
@@ -319,7 +327,7 @@ impl DataFileReader {
                 file_meta.file_size as u64,
                 &format_read_fields,
                 file_predicates.as_ref(),
-                None,
+                batch_size,
                 row_selection,
             ).await?;
 
diff --git a/crates/paimon/src/table/format_read_builder.rs 
b/crates/paimon/src/table/format_read_builder.rs
index 4fa03b62..f97805c0 100644
--- a/crates/paimon/src/table/format_read_builder.rs
+++ b/crates/paimon/src/table/format_read_builder.rs
@@ -21,7 +21,7 @@ use super::partition_filter::PartitionFilter;
 use super::read_builder::split_scan_predicates;
 use super::read_builder::{resolve_projected_fields, 
validate_projection_possible};
 use super::{Table, TableRead, TableScan};
-use crate::spec::{CoreOptions, DataField, Predicate};
+use crate::spec::{DataField, Predicate};
 use crate::table::source::RowRange;
 use crate::Result;
 
@@ -114,7 +114,8 @@ impl<'a> FormatReadBuilder<'a> {
     }
 
     pub(crate) fn new_read(&self) -> Result<TableRead<'a>> {
-        
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        let core_options = self.table.schema().core_options();
+        core_options.ensure_read_authorized()?;
         let read_type = match self.resolve_read_type()? {
             None => self.table.schema().fields().to_vec(),
             Some(fields) => fields,
diff --git a/crates/paimon/src/table/format_table_read.rs 
b/crates/paimon/src/table/format_table_read.rs
index 398ec912..0d561ab1 100644
--- a/crates/paimon/src/table/format_table_read.rs
+++ b/crates/paimon/src/table/format_table_read.rs
@@ -21,7 +21,7 @@ use super::data_file_reader::DataFileReader;
 use super::read_builder::split_scan_predicates;
 use super::{ArrowRecordBatchStream, Table};
 use crate::arrow::{build_target_arrow_schema, paimon_type_to_arrow};
-use crate::spec::{extract_datum, BinaryRow, CoreOptions, DataField, DataType, 
Datum, Predicate};
+use crate::spec::{extract_datum, BinaryRow, DataField, DataType, Datum, 
Predicate};
 use crate::{DataSplit, Error};
 use arrow_array::{
     new_null_array, ArrayRef, BinaryArray, BooleanArray, Date32Array, 
Float32Array, Float64Array,
@@ -77,7 +77,8 @@ impl<'a> FormatTableRead<'a> {
         &self,
         data_splits: &[DataSplit],
     ) -> crate::Result<ArrowRecordBatchStream> {
-        
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        let core_options = self.table.schema().core_options();
+        core_options.ensure_read_authorized()?;
         let read_type = self.read_type.clone();
         let output_schema = build_target_arrow_schema(&read_type)?;
         let partition_keys = self.table.schema().partition_keys().to_vec();
@@ -93,6 +94,7 @@ impl<'a> FormatTableRead<'a> {
         let schema_manager = self.table.schema_manager().clone();
         let schema_id = self.table.schema().id();
         let mut remaining = self.limit;
+        let batch_size = Some(core_options.read_batch_size()?);
 
         Ok(try_stream! {
             for split in splits {
@@ -108,6 +110,7 @@ impl<'a> FormatTableRead<'a> {
                     data_read_type.clone(),
                     data_predicates.clone(),
                 )
+                .with_batch_size(batch_size)
                 .read(std::slice::from_ref(&split))?;
 
                 while let Some(batch) = stream.next().await {
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index 2a6224b8..4450b273 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -55,6 +55,8 @@ pub(crate) struct KeyValueFileReader {
     /// of a key survives); they are enforced by the post-merge residual
     /// filter using the full `config.predicates` instead.
     pushdown_predicates: Vec<Predicate>,
+    #[cfg(test)]
+    input_batch_sizes: Option<std::sync::Arc<std::sync::Mutex<Vec<usize>>>>,
 }
 
 /// Configuration for [`KeyValueFileReader`], grouping table schema and
@@ -70,6 +72,7 @@ pub(crate) struct KeyValueReadConfig {
     pub primary_keys: Vec<String>,
     pub merge_engine: MergeEngine,
     pub sequence_fields: Vec<String>,
+    pub read_batch_size: usize,
 }
 
 /// Keep only the conjuncts of `predicates` that reference primary-key columns,
@@ -115,9 +118,20 @@ impl KeyValueFileReader {
             file_io,
             config,
             pushdown_predicates,
+            #[cfg(test)]
+            input_batch_sizes: None,
         }
     }
 
+    #[cfg(test)]
+    fn with_input_batch_sizes(
+        mut self,
+        input_batch_sizes: std::sync::Arc<std::sync::Mutex<Vec<usize>>>,
+    ) -> Self {
+        self.input_batch_sizes = Some(input_batch_sizes);
+        self
+    }
+
     fn new_merge_function(
         merge_engine: MergeEngine,
         table_options: &HashMap<String, String>,
@@ -313,6 +327,9 @@ impl KeyValueFileReader {
         let residual_predicates = self.config.predicates;
         let primary_keys = self.config.primary_keys;
         let sequence_fields = self.config.sequence_fields;
+        let read_batch_size = self.config.read_batch_size;
+        #[cfg(test)]
+        let input_batch_sizes = self.input_batch_sizes;
 
         // Build the merge output schema (keys + values, no system columns).
         let mut merge_output_fields: Vec<DataField> = Vec::new();
@@ -350,7 +367,8 @@ impl KeyValueFileReader {
                         table_fields.clone(),
                         internal_read_type.clone(),
                         pushdown_predicates.clone(),
-                    );
+                    )
+                    .with_batch_size(Some(read_batch_size));
 
                     let stream = reader.read_single_file_stream(
                         split,
@@ -359,6 +377,18 @@ impl KeyValueFileReader {
                         None,
                         None,
                     )?;
+                    #[cfg(test)]
+                    let stream = if let Some(batch_sizes) = 
input_batch_sizes.clone() {
+                        stream
+                            .inspect(move |batch| {
+                                if let Ok(batch) = batch {
+                                    
batch_sizes.lock().unwrap().push(batch.num_rows());
+                                }
+                            })
+                            .boxed()
+                    } else {
+                        stream
+                    };
                     file_streams.push(stream);
                 }
 
@@ -454,6 +484,7 @@ mod tests {
     use crate::table::{Table, TableWrite};
     use arrow_array::{Array, Int32Array};
     use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
+    use futures::TryStreamExt;
     use std::sync::Arc;
 
     fn test_file_io() -> FileIO {
@@ -646,6 +677,70 @@ mod tests {
         assert!(matches!(&kept[0], Predicate::AlwaysTrue));
     }
 
+    #[tokio::test]
+    async fn 
kv_input_decode_honors_read_batch_size_without_changing_merge_batching() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_read_batch_size";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[("read.batch-size", 
"2")]);
+
+        write_commit(
+            &table,
+            &int_batch(
+                vec![1, 2, 3, 4, 5],
+                vec![Some(10), Some(20), Some(30), Some(40), Some(50)],
+            ),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(
+                vec![1, 2, 3, 4, 5],
+                vec![Some(11), Some(21), Some(31), Some(41), Some(51)],
+            ),
+        )
+        .await;
+
+        let read_builder = table.new_read_builder();
+        let plan = read_builder.new_scan().plan().await.unwrap();
+        let core_options = table.schema().core_options();
+        let input_batch_sizes = Arc::new(std::sync::Mutex::new(Vec::new()));
+        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: core_options
+                    .sequence_fields()
+                    .iter()
+                    .map(|field| field.to_string())
+                    .collect(),
+                read_batch_size: core_options.read_batch_size().unwrap(),
+            },
+        )
+        .with_input_batch_sizes(input_batch_sizes.clone());
+        let batches = reader
+            .read(plan.splits())
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        let mut decoded_batch_sizes = 
input_batch_sizes.lock().unwrap().clone();
+        decoded_batch_sizes.sort_unstable();
+        assert_eq!(decoded_batch_sizes, vec![1, 1, 2, 2, 2, 2]);
+        assert_eq!(batches.len(), 1, "merge output batching stays 
independent");
+        assert_eq!(batches[0].num_rows(), 5);
+        assert_eq!(int_column(&batches, "value"), vec![11, 21, 31, 41, 51]);
+    }
+
     /// Non-PK equality filter on a dedup PK table read through the sort-merge
     /// path must return only matching rows. Before the post-merge residual,
     /// the non-PK conjunct was silently dropped and all rows came back.
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 6046ab0b..a3d390fd 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -450,7 +450,8 @@ impl<'a> PaimonReadBuilder<'a> {
     pub fn new_read(&self) -> Result<TableRead<'a>> {
         // Fail closed at read construction so bindings that short-circuit 
before
         // `to_arrow` (e.g. an empty-splits fast path) can't bypass the guard.
-        
CoreOptions::new(self.table.schema.options()).ensure_read_authorized()?;
+        let core_options = self.table.schema.core_options();
+        core_options.ensure_read_authorized()?;
         let read_type = match self.resolve_read_type()? {
             None => self.table.schema.fields().to_vec(),
             Some(fields) => fields,
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index f3d1f64e..d95c0104 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -229,7 +229,7 @@ impl<'a> PaimonTableRead<'a> {
         }
         // Delta / Changelog rows are read as-is from planned files (no 
full-table
         // merge against historical base versions).
-        self.new_data_file_reader().read(&data_splits)
+        self.new_data_file_reader()?.read(&data_splits)
     }
 
     /// Returns an audit-log stream for a planned incremental scan.
@@ -285,7 +285,8 @@ impl<'a> PaimonTableRead<'a> {
             self.table.schema.fields().to_vec(),
             read_type,
             self.data_predicates.clone(),
-        );
+        )
+        
.with_batch_size(Some(self.table.schema().core_options().read_batch_size()?));
         let raw_stream = reader.read(&data_splits)?;
 
         Ok(Box::pin(async_stream::try_stream! {
@@ -340,7 +341,7 @@ impl<'a> PaimonTableRead<'a> {
     /// 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();
-        let core_options = CoreOptions::new(self.table.schema.options());
+        let core_options = self.table.schema.core_options();
         // Fail closed for a direct `TableRead` (bypassing 
`ReadBuilder::new_read`).
         core_options.ensure_read_authorized()?;
         let merge_engine = core_options.merge_engine()?;
@@ -440,6 +441,7 @@ impl<'a> PaimonTableRead<'a> {
                     .iter()
                     .map(|s| s.to_string())
                     .collect(),
+                read_batch_size: core_options.read_batch_size()?,
             },
         );
         reader.read(splits)
@@ -463,17 +465,18 @@ impl<'a> PaimonTableRead<'a> {
             core_options.blob_view_fields(),
             core_options.blob_view_resolve_enabled(),
             self.table.rest_env().cloned(),
-        )?;
+        )?
+        .with_batch_size(Some(core_options.read_batch_size()?));
         reader.read(data_splits)
     }
 
     /// Read raw data files without dedup or evolution.
     fn read_raw(&self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
-        self.new_data_file_reader().read(data_splits)
+        self.new_data_file_reader()?.read(data_splits)
     }
 
-    fn new_data_file_reader(&self) -> DataFileReader {
-        DataFileReader::new(
+    fn new_data_file_reader(&self) -> crate::Result<DataFileReader> {
+        Ok(DataFileReader::new(
             self.table.file_io.clone(),
             self.table.schema_manager().clone(),
             self.table.schema().id(),
@@ -481,6 +484,7 @@ impl<'a> PaimonTableRead<'a> {
             self.read_type().to_vec(),
             self.data_predicates.clone(),
         )
+        
.with_batch_size(Some(self.table.schema().core_options().read_batch_size()?)))
     }
 }
 


Reply via email to