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 a156316 fix: merge primary-key rows correctly when bucket files span
multiple splits (#374)
a156316 is described below
commit a1563168c35ee893675bb90e7dbdf5fb2f992137
Author: chaoyang <[email protected]>
AuthorDate: Sat Jun 13 11:05:08 2026 +0800
fix: merge primary-key rows correctly when bucket files span multiple
splits (#374)
plan_snapshot bin-packed a bucket's files purely by size, so files whose
primary-key ranges overlap could land in different splits. Each split
runs its own sort-merge reader, so the same key surfaced once per split
instead of merging to a single row (reproducible with three commits of
one key under source.split.target-size=1b).
Port the Java MergeTreeSplitGenerator/IntervalPartition algorithm: sort
files by decoded (min_key, max_key), group transitively overlapping
files into sections, then bin-pack whole sections via pack_for_ordered.
Keys are decoded with the trimmed-PK data types and compared through
datum_cmp — BinaryRow stores fields little-endian, so raw byte
comparison would order int 256 before int 1. Undecodable key ranges
collapse the bucket into one section, trading parallelism for
correctness. Append-only tables keep the file-level bin pack.
---
crates/integrations/datafusion/tests/pk_tables.rs | 242 ++++++++-
crates/paimon/src/table/data_evolution_writer.rs | 3 +
crates/paimon/src/table/kv_file_reader.rs | 17 +-
.../paimon/src/table/merge_tree_split_generator.rs | 591 +++++++++++++++++++++
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/source.rs | 167 +++++-
crates/paimon/src/table/table_read.rs | 112 +++-
crates/paimon/src/table/table_scan.rs | 159 +++++-
crates/paimon/src/table/table_write.rs | 135 +++++
9 files changed, 1376 insertions(+), 51 deletions(-)
diff --git a/crates/integrations/datafusion/tests/pk_tables.rs
b/crates/integrations/datafusion/tests/pk_tables.rs
index bf1a317..473413c 100644
--- a/crates/integrations/datafusion/tests/pk_tables.rs
+++ b/crates/integrations/datafusion/tests/pk_tables.rs
@@ -19,7 +19,8 @@
//!
//! Covers: basic write+read, dedup within/across commits, partitioned PK
tables,
//! multi-bucket, column projection, FirstRow merge engine, sequence.field,
-//! INSERT OVERWRITE, filter pushdown, and error cases.
+//! INSERT OVERWRITE, filter pushdown, cross-split merge correctness, and
+//! error cases.
//!
//! Dynamic bucket and cross-partition tests are in separate files:
//! - `dynamic_bucket_tables.rs`
@@ -28,8 +29,8 @@
mod common;
use common::{
- collect_id_name, collect_id_value, create_sql_context, create_test_env,
row_count,
- setup_sql_context,
+ collect_id_name, collect_id_value, collect_int_int_str,
create_sql_context, create_test_env,
+ row_count, setup_sql_context,
};
use datafusion::arrow::array::{Array, Int32Array, StringArray};
use paimon::catalog::Identifier;
@@ -1979,3 +1980,238 @@ async fn test_pk_dv_deduplicate_read_no_error() {
result.err()
);
}
+
+// ======================= Cross-Split Merge Correctness
=======================
+
+/// Regression: a 1-byte split target forces every data file into its own
+/// split candidate. Files holding versions of the same key overlap on key
+/// range and must still be merged into a single row — previously each split
+/// emitted its own (stale) version.
+#[tokio::test]
+async fn test_pk_dedup_merges_across_tiny_splits() {
+ let (_tmp, sql_context) = setup_sql_context().await;
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t_tiny_split (
+ id INT NOT NULL, value INT,
+ PRIMARY KEY (id)
+ ) WITH (
+ 'bucket' = '1',
+ 'source.split.target-size' = '1b',
+ 'source.split.open-file-cost' = '1b'
+ )",
+ )
+ .await
+ .unwrap();
+
+ for value in [10, 20, 30] {
+ sql_context
+ .sql(&format!(
+ "INSERT INTO paimon.test_db.t_tiny_split VALUES (1, {value})"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ }
+
+ let rows = collect_id_value(
+ &sql_context,
+ "SELECT id, value FROM paimon.test_db.t_tiny_split",
+ )
+ .await;
+ assert_eq!(rows, vec![(1, 30)]);
+}
+
+/// LIMIT must not be starved by merge-needed splits: the three versions of
+/// key 1 share one split whose physical row count (3) overstates its single
+/// logical row. Such splits report an unknown merged row count, so limit
+/// pushdown cannot stop before the split holding key 2.
+#[tokio::test]
+async fn test_pk_limit_not_starved_by_merge_splits() {
+ let (_tmp, sql_context) = setup_sql_context().await;
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t_tiny_split_limit (
+ id INT NOT NULL, value INT,
+ PRIMARY KEY (id)
+ ) WITH (
+ 'bucket' = '1',
+ 'source.split.target-size' = '1b',
+ 'source.split.open-file-cost' = '1b'
+ )",
+ )
+ .await
+ .unwrap();
+
+ // Three versions of key 1 (one overlapping section), then key 2.
+ for value in [10, 20, 30] {
+ sql_context
+ .sql(&format!(
+ "INSERT INTO paimon.test_db.t_tiny_split_limit VALUES (1,
{value})"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ }
+ sql_context
+ .sql("INSERT INTO paimon.test_db.t_tiny_split_limit VALUES (2, 200)")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ // Two logical rows exist; LIMIT 2 must return both.
+ let returned = row_count(
+ &sql_context,
+ "SELECT id, value FROM paimon.test_db.t_tiny_split_limit LIMIT 2",
+ )
+ .await;
+ assert_eq!(returned, 2, "LIMIT 2 must yield 2 rows");
+
+ // COUNT(*) must reflect logical rows, not the physical (pre-merge) count
+ // that DataFusion could otherwise read from exact scan statistics.
+ let batches = sql_context
+ .sql("SELECT COUNT(*) FROM paimon.test_db.t_tiny_split_limit")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ let count = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<datafusion::arrow::array::Int64Array>()
+ .unwrap()
+ .value(0);
+ assert_eq!(count, 2, "COUNT(*) must count merged rows");
+}
+
+/// Partial-update files can hold several physical rows of one key (the
+/// writer keeps all rows for read-side field-wise merge), so their splits
+/// must never report physical row counts as merged row counts: COUNT(*) has
+/// to count merged rows and LIMIT must not be starved.
+#[tokio::test]
+async fn test_pk_partial_update_count_and_limit_see_merged_rows() {
+ let (_tmp, sql_context) = setup_sql_context().await;
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t_pu_count (
+ id INT NOT NULL, v_int INT, v_str STRING,
+ PRIMARY KEY (id)
+ ) WITH (
+ 'bucket' = '1',
+ 'merge-engine' = 'partial-update',
+ 'source.split.target-size' = '1b',
+ 'source.split.open-file-cost' = '1b'
+ )",
+ )
+ .await
+ .unwrap();
+
+ // One INSERT writes three partial updates of key 1 into a single file,
+ // plus an independent key 2.
+ sql_context
+ .sql(
+ "INSERT INTO paimon.test_db.t_pu_count VALUES
+ (1, 10, CAST(NULL AS STRING)),
+ (1, CAST(NULL AS INT), 'hello'),
+ (1, 100, CAST(NULL AS STRING)),
+ (2, 200, 'world')",
+ )
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ // COUNT(*) must count merged rows, not the physical rows a single file
+ // holds (DataFusion may answer COUNT(*) from exact scan statistics).
+ let batches = sql_context
+ .sql("SELECT COUNT(*) FROM paimon.test_db.t_pu_count")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ let count = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<datafusion::arrow::array::Int64Array>()
+ .unwrap()
+ .value(0);
+ assert_eq!(count, 2, "COUNT(*) must count merged rows");
+
+ // Two logical rows exist; LIMIT 2 must not be starved by the
+ // multi-version file of key 1.
+ let returned = row_count(
+ &sql_context,
+ "SELECT id, v_int FROM paimon.test_db.t_pu_count LIMIT 2",
+ )
+ .await;
+ assert_eq!(returned, 2, "LIMIT 2 must yield 2 rows");
+}
+
+/// Same regression for the partial-update engine: per-column updates of one
+/// key spread over three commits/files must merge into a single row even
+/// when the split target would otherwise separate the files.
+#[tokio::test]
+async fn test_pk_partial_update_merges_across_tiny_splits() {
+ let (_tmp, sql_context) = setup_sql_context().await;
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t_tiny_split_pu (
+ id INT NOT NULL, v_int INT, v_str STRING,
+ PRIMARY KEY (id)
+ ) WITH (
+ 'bucket' = '1',
+ 'merge-engine' = 'partial-update',
+ 'source.split.target-size' = '1b',
+ 'source.split.open-file-cost' = '1b'
+ )",
+ )
+ .await
+ .unwrap();
+
+ sql_context
+ .sql("INSERT INTO paimon.test_db.t_tiny_split_pu VALUES (1, 10,
CAST(NULL AS STRING))")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ sql_context
+ .sql("INSERT INTO paimon.test_db.t_tiny_split_pu VALUES (1, CAST(NULL
AS INT), 'hello')")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ sql_context
+ .sql("INSERT INTO paimon.test_db.t_tiny_split_pu VALUES (1, 100,
CAST(NULL AS STRING))")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ let batches = sql_context
+ .sql("SELECT id, v_int, v_str FROM paimon.test_db.t_tiny_split_pu")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ collect_int_int_str(&batches),
+ vec![(1, 100, "hello".to_string())]
+ );
+}
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index f63ac24..4afe198 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -222,6 +222,8 @@ impl DataEvolutionWriter {
rb.with_projection(&col_refs);
let read = rb.new_read()?;
+ // Base + partial-column files share row-id ranges, so physical
+ // row counts overcount the group's logical rows.
let split = DataSplitBuilder::new()
.with_snapshot(file_range.snapshot_id)
.with_partition(BinaryRow::from_serialized_bytes(&file_range.partition)?)
@@ -229,6 +231,7 @@ impl DataEvolutionWriter {
.with_bucket_path(file_range.bucket_path.clone())
.with_total_buckets(file_range.total_buckets)
.with_data_files(file_range.files.clone())
+ .with_raw_convertible(file_range.files.len() == 1)
.build()?;
let stream = read.to_arrow(&[split])?;
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index 128b2da..9a6f007 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -37,7 +37,7 @@ use crate::spec::{
use crate::table::schema_manager::SchemaManager;
use crate::table::ArrowRecordBatchStream;
use crate::{DataSplit, Error};
-use arrow_array::RecordBatch;
+use arrow_array::{RecordBatch, RecordBatchOptions};
use async_stream::try_stream;
use futures::StreamExt;
@@ -339,11 +339,16 @@ impl KeyValueFileReader {
.iter()
.map(|&src| batch.column(src).clone())
.collect();
- let reordered =
RecordBatch::try_new(output_schema.clone(), columns)
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to reorder merged
RecordBatch: {e}"),
- source: Some(Box::new(e)),
- })?;
+ // An explicit row count keeps empty projections working
+ // (e.g. COUNT(*) reads no columns).
+ let options =
+
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+ let reordered =
+
RecordBatch::try_new_with_options(output_schema.clone(), columns, &options)
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to reorder merged
RecordBatch: {e}"),
+ source: Some(Box::new(e)),
+ })?;
yield reordered;
}
}
diff --git a/crates/paimon/src/table/merge_tree_split_generator.rs
b/crates/paimon/src/table/merge_tree_split_generator.rs
new file mode 100644
index 0000000..276aea5
--- /dev/null
+++ b/crates/paimon/src/table/merge_tree_split_generator.rs
@@ -0,0 +1,591 @@
+// 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.
+
+//! Split generation for primary-key (merge-tree) tables.
+//!
+//! Files whose primary-key ranges overlap must be read by the same
+//! sort-merge reader, so they have to stay in the same split. This module
+//! groups a bucket's files into key-range "sections" and then bin-packs
+//! whole sections into splits, mirroring the Java implementation.
+//!
+//! References:
+//!
[MergeTreeSplitGenerator](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/MergeTreeSplitGenerator.java),
+//!
[IntervalPartition](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/IntervalPartition.java)
+
+use super::bin_pack::pack_for_ordered;
+use crate::spec::{datum_cmp, BinaryRow, DataFileMeta, DataType, Datum,
TableSchema};
+use std::cmp::{self, Ordering};
+
+/// Compares serialized `BinaryRow` keys field-by-field using the trimmed
+/// primary-key data types.
+///
+/// BinaryRow stores fields little-endian, so raw byte comparison would order
+/// e.g. int 256 (`[00 01 00 00]`) before int 1 (`[01 00 00 00]`); keys must
+/// be decoded before comparing.
+pub(crate) struct KeyComparator {
+ key_types: Vec<DataType>,
+}
+
+/// A decoded key: one `Option<Datum>` per trimmed primary-key field
+/// (`None` = SQL NULL).
+type DecodedKey = Vec<Option<Datum>>;
+
+impl KeyComparator {
+ pub(crate) fn new(key_types: Vec<DataType>) -> Self {
+ Self { key_types }
+ }
+
+ /// Build a comparator over a table's trimmed primary keys, matching the
+ /// key layout the kv writer uses for min/max keys. Returns `None` for
+ /// tables without primary keys.
+ pub(crate) fn from_table_schema(schema: &TableSchema) -> Option<Self> {
+ let trimmed_pks = schema.trimmed_primary_keys();
+ if trimmed_pks.is_empty() {
+ return None;
+ }
+ let fields = schema.fields();
+ let key_types: Vec<DataType> = trimmed_pks
+ .iter()
+ .filter_map(|name| {
+ fields
+ .iter()
+ .find(|f| f.name() == name)
+ .map(|f| f.data_type().clone())
+ })
+ .collect();
+ // A PK name missing from the fields (should not happen) leaves the
+ // arity short; decode then fails and callers degrade safely.
+ Some(Self::new(key_types))
+ }
+
+ /// Decode a serialized min/max key. Returns `None` when the key is empty
+ /// or malformed, letting callers degrade to the safe "treat everything as
+ /// overlapping" path instead of failing the scan.
+ fn decode(&self, key: &[u8]) -> Option<DecodedKey> {
+ if key.is_empty() {
+ return None;
+ }
+ let row = BinaryRow::from_serialized_bytes(key).ok()?;
+ if (row.arity() as usize) != self.key_types.len() {
+ return None;
+ }
+ self.key_types
+ .iter()
+ .enumerate()
+ .map(|(pos, dt)| row.get_datum(pos, dt).ok())
+ .collect()
+ }
+}
+
+/// Compare decoded keys field-by-field. NULL sorts first; fields that
+/// `datum_cmp` cannot order (e.g. float NaN) compare as equal, which forces
+/// the files into the same section — conservative but never incorrect.
+fn compare_decoded(a: &DecodedKey, b: &DecodedKey) -> Ordering {
+ for (fa, fb) in a.iter().zip(b.iter()) {
+ let ord = match (fa, fb) {
+ (None, None) => Ordering::Equal,
+ (None, Some(_)) => Ordering::Less,
+ (Some(_), None) => Ordering::Greater,
+ (Some(da), Some(db)) => datum_cmp(da,
db).unwrap_or(Ordering::Equal),
+ };
+ if ord != Ordering::Equal {
+ return ord;
+ }
+ }
+ Ordering::Equal
+}
+
+/// A file paired with its decoded min/max keys.
+struct KeyedFile {
+ file: DataFileMeta,
+ min: DecodedKey,
+ max: DecodedKey,
+}
+
+/// Decode every file's key range up front. Returns `None` if any file lacks
+/// a usable key range, in which case callers must assume full overlap.
+fn decode_all(
+ files: Vec<DataFileMeta>,
+ comparator: &KeyComparator,
+) -> Result<Vec<KeyedFile>, Vec<DataFileMeta>> {
+ let mut keyed = Vec::with_capacity(files.len());
+ let mut undecodable = false;
+ for file in &files {
+ match (
+ comparator.decode(&file.min_key),
+ comparator.decode(&file.max_key),
+ ) {
+ (Some(min), Some(max)) if !undecodable => keyed.push(KeyedFile {
+ file: file.clone(),
+ min,
+ max,
+ }),
+ _ => undecodable = true,
+ }
+ }
+ if undecodable {
+ Err(files)
+ } else {
+ Ok(keyed)
+ }
+}
+
+/// Group files into sections by primary-key range overlap.
+///
+/// Files are sorted by `(min_key, max_key)`; a running upper bound tracks the
+/// max key seen in the current section, and a file whose min key exceeds the
+/// bound starts a new section. Sections never overlap each other, while files
+/// inside one section all transitively overlap and must be merged together.
+///
+/// Files with empty or undecodable key ranges collapse everything into one
+/// section: no parallelism, but never a missed merge.
+pub(crate) fn interval_partition(
+ files: Vec<DataFileMeta>,
+ comparator: &KeyComparator,
+) -> Vec<Vec<DataFileMeta>> {
+ if files.len() <= 1 {
+ return if files.is_empty() {
+ Vec::new()
+ } else {
+ vec![files]
+ };
+ }
+
+ let mut keyed = match decode_all(files, comparator) {
+ Ok(keyed) => keyed,
+ Err(files) => return vec![files],
+ };
+ keyed.sort_by(|a, b| {
+ compare_decoded(&a.min, &b.min).then_with(|| compare_decoded(&a.max,
&b.max))
+ });
+
+ let mut sections: Vec<Vec<DataFileMeta>> = Vec::new();
+ let mut current: Vec<DataFileMeta> = Vec::new();
+ let mut bound: Option<DecodedKey> = None;
+
+ for kf in keyed {
+ if let Some(ref b) = bound {
+ if compare_decoded(&kf.min, b) == Ordering::Greater {
+ sections.push(std::mem::take(&mut current));
+ bound = None;
+ }
+ }
+ match bound {
+ Some(ref b) if compare_decoded(&kf.max, b) != Ordering::Greater =>
{}
+ _ => bound = Some(kf.max),
+ }
+ current.push(kf.file);
+ }
+ if !current.is_empty() {
+ sections.push(current);
+ }
+ sections
+}
+
+/// Bin-pack whole sections into splits. A section is atomic: its files
+/// overlap on primary key and must never be separated, even when the section
+/// alone exceeds `target_split_size`.
+///
+/// Mirrors Java `MergeTreeSplitGenerator#packSplits`: a section's weight is
+/// `max(total file size, open_file_cost)` — the open-file cost is charged
+/// once per section, not per file.
+pub(crate) fn pack_sections(
+ sections: Vec<Vec<DataFileMeta>>,
+ target_split_size: i64,
+ open_file_cost: i64,
+) -> Vec<Vec<DataFileMeta>> {
+ pack_for_ordered(
+ sections,
+ |section| {
+ cmp::max(
+ section.iter().map(|f| f.file_size).sum::<i64>(),
+ open_file_cost,
+ )
+ },
+ target_split_size,
+ )
+ .into_iter()
+ .map(|sections| sections.into_iter().flatten().collect())
+ .collect()
+}
+
+/// A group of files forming one split, plus whether the split can be read
+/// raw — without the sort-merge reader — so its physical row count equals
+/// its logical row count.
+///
+/// Mirrors Java `SplitGenerator.SplitGroup`.
+#[derive(Debug)]
+pub(crate) struct SplitGroup {
+ pub(crate) files: Vec<DataFileMeta>,
+ pub(crate) raw_convertible: bool,
+}
+
+/// Whether a file is known to contain no DELETE rows.
+///
+/// Mirrors Java `MergeTreeSplitGenerator#withoutDeleteRow`: a missing
+/// `delete_row_count` is treated as "no deletes" for compatibility with files
+/// written by old versions.
+fn without_delete_row(file: &DataFileMeta) -> bool {
+ file.delete_row_count.is_none_or(|count| count == 0)
+}
+
+/// Generate batch splits for a merge-tree (primary-key) bucket.
+///
+/// Mirrors Java `MergeTreeSplitGenerator#splitForBatch` for the merging read
+/// path (deletion-vector and first-row tables are routed to plain size-based
+/// packing before reaching this function, matching Java's
+/// `alwaysRawConvertible` fast path):
+///
+/// * If every file is compacted (level != 0), has no delete rows, and all
+/// files sit on a single level, no two files can overlap on key range, so
+/// the files are bin-packed individually and every group is raw
+/// convertible.
+/// * Otherwise files are sectioned by key-range overlap and whole sections
+/// are bin-packed; a group is raw convertible only when it holds exactly
+/// one file without delete rows.
+///
+/// `file_keys_unique` is a deliberate deviation from Java: raw convertibility
+/// additionally assumes a file never holds two rows of one key. Java's
+/// `MergeTreeWriter#flushWriteBuffer` runs the merge function before flushing,
+/// so that holds for every engine; the Rust writer only deduplicates at flush
+/// for deduplicate/first-row, while partial-update keeps all rows for
+/// read-side field-wise merge (`kv_file_writer.rs`, `select_flush_indices`).
+/// Callers pass `false` for engines without that write-time guarantee, forcing
+/// every group non-raw-convertible. Can be relaxed once the writer merges on
+/// flush like Java.
+pub(crate) fn merge_tree_split_for_batch(
+ files: Vec<DataFileMeta>,
+ comparator: &KeyComparator,
+ target_split_size: i64,
+ open_file_cost: i64,
+ file_keys_unique: bool,
+) -> Vec<SplitGroup> {
+ let raw_convertible = files.iter().all(|f| f.level != 0 &&
without_delete_row(f));
+ let one_level = {
+ let mut levels: Vec<i32> = files.iter().map(|f| f.level).collect();
+ levels.sort_unstable();
+ levels.dedup();
+ levels.len() == 1
+ };
+
+ if raw_convertible && one_level {
+ return pack_for_ordered(
+ files,
+ |f| cmp::max(f.file_size, open_file_cost),
+ target_split_size,
+ )
+ .into_iter()
+ .map(|files| SplitGroup {
+ files,
+ raw_convertible: file_keys_unique,
+ })
+ .collect();
+ }
+
+ pack_sections(
+ interval_partition(files, comparator),
+ target_split_size,
+ open_file_cost,
+ )
+ .into_iter()
+ .map(|files| {
+ let raw_convertible = file_keys_unique && files.len() == 1 &&
without_delete_row(&files[0]);
+ SplitGroup {
+ files,
+ raw_convertible,
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{BinaryRowBuilder, IntType};
+ use chrono::{DateTime, Utc};
+
+ fn int_key(value: i32) -> Vec<u8> {
+ let mut builder = BinaryRowBuilder::new(1);
+ builder.write_int(0, value);
+ builder.build_serialized()
+ }
+
+ fn keyed_file(name: &str, min: i32, max: i32, file_size: i64, level: i32)
-> DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size,
+ row_count: 100,
+ min_key: int_key(min),
+ max_key: int_key(max),
+ 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,
+ extra_files: Vec::new(),
+ creation_time: DateTime::<Utc>::from_timestamp(0, 0),
+ delete_row_count: None,
+ embedded_index: None,
+ first_row_id: None,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ fn int_comparator() -> KeyComparator {
+ KeyComparator::new(vec![DataType::Int(IntType::new())])
+ }
+
+ fn section_names(sections: &[Vec<DataFileMeta>]) -> Vec<Vec<&str>> {
+ sections
+ .iter()
+ .map(|s| s.iter().map(|f| f.file_name.as_str()).collect())
+ .collect()
+ }
+
+ /// Int keys must be ordered numerically, not by little-endian bytes
+ /// (byte-wise, 256 = [00 01 00 00] would sort before 1 = [01 00 00 00]).
+ #[test]
+ fn key_comparator_orders_ints_numerically() {
+ let comparator = int_comparator();
+ let one = comparator.decode(&int_key(1)).unwrap();
+ let two = comparator.decode(&int_key(2)).unwrap();
+ let big = comparator.decode(&int_key(256)).unwrap();
+ assert_eq!(compare_decoded(&one, &two), Ordering::Less);
+ assert_eq!(compare_decoded(&two, &big), Ordering::Less);
+ assert_eq!(compare_decoded(&big, &one), Ordering::Greater);
+ }
+
+ #[test]
+ fn interval_partition_groups_overlapping_files() {
+ let files = vec![
+ keyed_file("a", 1, 10, 100, 0),
+ keyed_file("b", 5, 15, 100, 0),
+ keyed_file("c", 20, 30, 100, 0),
+ keyed_file("d", 25, 28, 100, 0),
+ ];
+ let sections = interval_partition(files, &int_comparator());
+ assert_eq!(
+ section_names(§ions),
+ vec![vec!["a", "b"], vec!["c", "d"]]
+ );
+ }
+
+ #[test]
+ fn interval_partition_keeps_disjoint_files_separate() {
+ let files = vec![
+ keyed_file("b", 3, 4, 100, 0),
+ keyed_file("a", 1, 2, 100, 0),
+ keyed_file("c", 5, 6, 100, 0),
+ ];
+ let sections = interval_partition(files, &int_comparator());
+ assert_eq!(
+ section_names(§ions),
+ vec![vec!["a"], vec!["b"], vec!["c"]]
+ );
+ }
+
+ /// A later file can extend the section bound past an earlier file's max:
+ /// [1,100] chains [50,60] and [90,110] into one section with [105,120].
+ #[test]
+ fn interval_partition_tracks_running_bound() {
+ let files = vec![
+ keyed_file("a", 1, 100, 100, 0),
+ keyed_file("b", 50, 60, 100, 0),
+ keyed_file("c", 90, 110, 100, 0),
+ keyed_file("d", 105, 120, 100, 0),
+ keyed_file("e", 121, 130, 100, 0),
+ ];
+ let sections = interval_partition(files, &int_comparator());
+ assert_eq!(
+ section_names(§ions),
+ vec![vec!["a", "b", "c", "d"], vec!["e"]]
+ );
+ }
+
+ #[test]
+ fn interval_partition_empty_key_degrades_to_single_section() {
+ let mut no_key = keyed_file("a", 1, 2, 100, 0);
+ no_key.min_key = Vec::new();
+ no_key.max_key = Vec::new();
+ let files = vec![no_key, keyed_file("b", 10, 20, 100, 0)];
+ let sections = interval_partition(files, &int_comparator());
+ assert_eq!(section_names(§ions), vec![vec!["a", "b"]]);
+ }
+
+ #[test]
+ fn pack_sections_respects_target_size() {
+ let sections = vec![
+ vec![keyed_file("a", 1, 2, 100, 0)],
+ vec![keyed_file("b", 3, 4, 100, 0)],
+ vec![keyed_file("c", 5, 6, 100, 0)],
+ ];
+ let splits = pack_sections(sections, 250, 1);
+ assert_eq!(section_names(&splits), vec![vec!["a", "b"], vec!["c"]]);
+ }
+
+ #[test]
+ fn pack_sections_never_splits_a_section() {
+ let sections = vec![vec![
+ keyed_file("a", 1, 10, 100, 0),
+ keyed_file("b", 5, 15, 100, 0),
+ ]];
+ let splits = pack_sections(sections, 50, 1);
+ assert_eq!(section_names(&splits), vec![vec!["a", "b"]]);
+ }
+
+ #[test]
+ fn pack_sections_applies_open_file_cost() {
+ let sections = vec![
+ vec![keyed_file("a", 1, 1, 2, 0)],
+ vec![keyed_file("b", 2, 2, 2, 0)],
+ vec![keyed_file("c", 3, 3, 2, 0)],
+ ];
+ // Weight per section is max(total file size=2, open_file_cost=100) =
100.
+ let splits = pack_sections(sections, 150, 100);
+ assert_eq!(
+ section_names(&splits),
+ vec![vec!["a"], vec!["b"], vec!["c"]]
+ );
+ }
+
+ /// The open-file cost is charged once per section, not per file (Java
+ /// `packSplits`): two 3-file sections weigh max(6, 100) = 100 each and
+ /// share one split under a 250 target, where a per-file charge (3 × 100)
+ /// would split them apart.
+ #[test]
+ fn pack_sections_charges_open_file_cost_per_section() {
+ let sections = vec![
+ vec![
+ keyed_file("a1", 1, 2, 2, 0),
+ keyed_file("a2", 1, 2, 2, 0),
+ keyed_file("a3", 1, 2, 2, 0),
+ ],
+ vec![
+ keyed_file("b1", 3, 4, 2, 0),
+ keyed_file("b2", 3, 4, 2, 0),
+ keyed_file("b3", 3, 4, 2, 0),
+ ],
+ ];
+ let splits = pack_sections(sections, 250, 100);
+ assert_eq!(
+ section_names(&splits),
+ vec![vec!["a1", "a2", "a3", "b1", "b2", "b3"]]
+ );
+ }
+
+ fn group_names(groups: &[SplitGroup]) -> Vec<Vec<&str>> {
+ groups
+ .iter()
+ .map(|g| g.files.iter().map(|f| f.file_name.as_str()).collect())
+ .collect()
+ }
+
+ /// All files compacted on one level: the fast path bin-packs files
+ /// individually and every group is raw convertible, even multi-file ones
+ /// (same-level files never overlap).
+ #[test]
+ fn split_for_batch_one_level_fast_path_is_raw_convertible() {
+ let comparator = int_comparator();
+ let files = vec![
+ keyed_file("a", 1, 10, 100, 5),
+ keyed_file("b", 11, 20, 100, 5),
+ keyed_file("c", 21, 30, 100, 5),
+ ];
+ let groups = merge_tree_split_for_batch(files, &comparator, 250, 1,
true);
+ assert_eq!(group_names(&groups), vec![vec!["a", "b"], vec!["c"]]);
+ assert!(groups.iter().all(|g| g.raw_convertible));
+ }
+
+ /// A delete-row file disables the fast path; after sectioning, only
+ /// single-file groups without delete rows stay raw convertible.
+ #[test]
+ fn split_for_batch_delete_rows_disable_raw_conversion() {
+ let comparator = int_comparator();
+ let mut with_deletes = keyed_file("del", 1, 10, 100, 5);
+ with_deletes.delete_row_count = Some(3);
+ let files = vec![with_deletes, keyed_file("clean", 11, 20, 100, 5)];
+ // Large target size packs both disjoint sections into one split.
+ let groups = merge_tree_split_for_batch(files, &comparator, 1000, 1,
true);
+ assert_eq!(group_names(&groups), vec![vec!["del", "clean"]]);
+ assert!(!groups[0].raw_convertible, "multi-file group is never raw");
+
+ // Tiny target size keeps each section alone; the delete-row file is
+ // still not raw convertible, the clean one is.
+ let mut with_deletes = keyed_file("del", 1, 10, 100, 5);
+ with_deletes.delete_row_count = Some(3);
+ let files = vec![with_deletes, keyed_file("clean", 11, 20, 100, 5)];
+ let groups = merge_tree_split_for_batch(files, &comparator, 1, 1,
true);
+ assert_eq!(group_names(&groups), vec![vec!["del"], vec!["clean"]]);
+ assert!(!groups[0].raw_convertible);
+ assert!(groups[1].raw_convertible);
+ }
+
+ /// Engines whose writer does not deduplicate at flush (partial-update
+ /// keeps all rows of a key in one file) cannot prove file-internal key
+ /// uniqueness: every group must stay non-raw-convertible on both the fast
+ /// path and the sectioned path, so physical row counts are never reported
+ /// as merged row counts.
+ #[test]
+ fn split_for_batch_without_unique_keys_never_raw_convertible() {
+ let comparator = int_comparator();
+
+ // Fast-path shape: all compacted, one level, no delete rows.
+ let files = vec![
+ keyed_file("a", 1, 10, 100, 5),
+ keyed_file("b", 11, 20, 100, 5),
+ ];
+ let groups = merge_tree_split_for_batch(files, &comparator, 250, 1,
false);
+ assert_eq!(group_names(&groups), vec![vec!["a", "b"]]);
+ assert!(groups.iter().all(|g| !g.raw_convertible));
+
+ // Sectioned shape: a disjoint single compacted file would be raw for
+ // deduplicate, but not without the write-time uniqueness guarantee.
+ let files = vec![
+ keyed_file("l0", 1, 50, 100, 0),
+ keyed_file("solo", 100, 120, 100, 2),
+ ];
+ let groups = merge_tree_split_for_batch(files, &comparator, 1, 1,
false);
+ assert_eq!(group_names(&groups), vec![vec!["l0"], vec!["solo"]]);
+ assert!(groups.iter().all(|g| !g.raw_convertible));
+ }
+
+ /// Level-0 or cross-level files take the sectioning path; overlapping
+ /// files share a non-raw-convertible group while a disjoint single file
+ /// stays raw convertible. Missing delete_row_count counts as "no deletes"
+ /// (old-version files).
+ #[test]
+ fn split_for_batch_sections_overlapping_files() {
+ let comparator = int_comparator();
+ let files = vec![
+ keyed_file("l0", 1, 50, 100, 0),
+ keyed_file("l1", 40, 90, 100, 1),
+ keyed_file("solo", 100, 120, 100, 2),
+ ];
+ let groups = merge_tree_split_for_batch(files, &comparator, 1, 1,
true);
+ assert_eq!(group_names(&groups), vec![vec!["l0", "l1"], vec!["solo"]]);
+ assert!(
+ !groups[0].raw_convertible,
+ "overlapping versions must merge"
+ );
+ assert!(groups[1].raw_convertible, "disjoint single compacted file");
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 6c4901a..3872c6a 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -37,6 +37,7 @@ mod full_text_search_builder;
pub(crate) mod global_index_scanner;
mod kv_file_reader;
mod kv_file_writer;
+pub(crate) mod merge_tree_split_generator;
mod partition_filter;
mod postpone_file_writer;
mod prepared_files;
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index 573d2f0..7d45742 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -425,6 +425,10 @@ pub struct DataSplit {
/// `None` at index `i` means no deletion file for `data_files[i]`
(matches Java getDeletionFiles() / List<DeletionFile> with null elements).
data_deletion_files: Option<Vec<Option<DeletionFile>>>,
row_ranges: Option<Vec<RowRange>>,
+ /// Whether the split can be read raw, without the merge reader: its
+ /// physical rows are exactly its logical rows (modulo deletion files).
+ /// Mirrors Java `DataSplit#rawConvertible`.
+ raw_convertible: bool,
}
impl DataSplit {
@@ -457,6 +461,12 @@ impl DataSplit {
self.row_ranges.as_deref()
}
+ /// Whether this split can be read raw (no sort-merge needed); see the
+ /// field doc. Mirrors Java `DataSplit#rawConvertible`.
+ pub fn raw_convertible(&self) -> bool {
+ self.raw_convertible
+ }
+
/// Returns the deletion file for the data file at the given index, if
any. `None` at that index means no deletion file.
pub fn deletion_file_for_data_file_index(&self, index: usize) ->
Option<&DeletionFile> {
self.data_deletion_files
@@ -487,21 +497,33 @@ impl DataSplit {
/// Returns the merged row count if it can be computed.
///
- /// Two paths:
- /// 1. If all files have `first_row_id` (data evolution mode): merge
overlapping
- /// row ID ranges and take max row count per group.
- /// 2. Otherwise: row_count() minus deleted rows (if deletion file
cardinality is known).
+ /// Two paths, checked in the same order as Java:
+ /// 1. Raw convertible splits (with all deletion-file cardinalities known):
+ /// physical row counts equal logical row counts, so sum `row_count`
+ /// minus deleted rows. Splits that need the sort-merge reader may
+ /// collapse multiple versions of a key into one row, so their physical
+ /// counts are only an upper bound and are never reported.
+ /// 2. If all files have `first_row_id` (data evolution mode): merge
+ /// overlapping row ID ranges and take max row count per group.
///
- /// Returns `None` if deletion files exist but cardinality is unknown.
+ /// Returns `None` otherwise.
///
/// Reference:
[DataSplit.mergedRowCount()](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java#L133)
pub fn merged_row_count(&self) -> Option<i64> {
- // Try data evolution merged row count first (all files have
first_row_id)
- if let Some(count) = self.data_evolution_merged_row_count() {
+ if let Some(count) = self.raw_merged_row_count() {
return Some(count);
}
+ self.data_evolution_merged_row_count()
+ }
- // Fallback: row_count - deleted rows
+ /// Physical row count minus deletions, valid only for raw convertible
+ /// splits with all deletion-file cardinalities known.
+ ///
+ /// Mirrors Java `rawMergedRowCountAvailable` + `rawMergedRowCount`.
+ fn raw_merged_row_count(&self) -> Option<i64> {
+ if !self.raw_convertible {
+ return None;
+ }
match &self.data_deletion_files {
None => Some(self.row_count()),
Some(deletion_files) => {
@@ -565,6 +587,7 @@ pub struct DataSplitBuilder {
/// Same length as data_files; `None` at index i = no deletion file for
data_files[i].
data_deletion_files: Option<Vec<Option<DeletionFile>>>,
row_ranges: Option<Vec<RowRange>>,
+ raw_convertible: bool,
}
impl DataSplitBuilder {
@@ -578,6 +601,10 @@ impl DataSplitBuilder {
data_files: None,
data_deletion_files: None,
row_ranges: None,
+ // Splits with no merge semantics (append tables, single-file
+ // utility splits) are raw by nature; the merge-tree and
+ // data-evolution scan paths set this explicitly per split group.
+ raw_convertible: true,
}
}
@@ -620,6 +647,12 @@ impl DataSplitBuilder {
self
}
+ /// Mark whether the split can be read raw; see
[`DataSplit::raw_convertible`].
+ pub fn with_raw_convertible(mut self, raw_convertible: bool) -> Self {
+ self.raw_convertible = raw_convertible;
+ self
+ }
+
pub fn build(self) -> crate::Result<DataSplit> {
if self.snapshot_id == -1 {
return Err(crate::Error::UnexpectedError {
@@ -672,6 +705,7 @@ impl DataSplitBuilder {
data_files,
data_deletion_files: self.data_deletion_files,
row_ranges: self.row_ranges,
+ raw_convertible: self.raw_convertible,
})
}
}
@@ -700,3 +734,120 @@ impl Plan {
&self.splits
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+
+ fn file(name: &str, row_count: i64, first_row_id: Option<i64>) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count,
+ 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: 1,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ first_row_id,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ fn split(files: Vec<DataFileMeta>, raw_convertible: bool) -> DataSplit {
+ DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .with_raw_convertible(raw_convertible)
+ .build()
+ .unwrap()
+ }
+
+ /// Raw convertible split without deletion files: physical sum is exact.
+ #[test]
+ fn test_merged_row_count_raw_convertible_sums_physical_rows() {
+ let s = split(vec![file("a", 10, None), file("b", 5, None)], true);
+ assert_eq!(s.merged_row_count(), Some(15));
+ }
+
+ /// Merge-needed split (multiple versions of a key may collapse): the
+ /// physical sum is only an upper bound, so the count is unknown.
+ /// Mirrors Java `rawMergedRowCountAvailable`.
+ #[test]
+ fn test_merged_row_count_unknown_for_merge_splits() {
+ let s = split(vec![file("a", 10, None), file("b", 5, None)], false);
+ assert_eq!(s.merged_row_count(), None);
+ }
+
+ /// Non-raw-convertible split where all files carry `first_row_id`: the
+ /// data-evolution branch still applies (overlapping row-id groups count
+ /// the max row count per group).
+ #[test]
+ fn test_merged_row_count_data_evolution_branch() {
+ let mut a = file("a", 10, Some(0));
+ a.row_count = 10;
+ let mut b = file("b", 4, Some(0));
+ b.row_count = 4;
+ let c = file("c", 7, Some(100));
+ let s = split(vec![a, b, c], false);
+ // a and b share row ids [0, ..): max(10, 4) = 10; c adds 7.
+ assert_eq!(s.merged_row_count(), Some(17));
+ }
+
+ /// Raw convertible split with a deletion file of known cardinality:
+ /// deleted rows are subtracted; unknown cardinality makes the raw branch
+ /// unavailable.
+ #[test]
+ fn test_merged_row_count_with_deletion_files() {
+ let s = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![file("a", 10, None)])
+ .with_data_deletion_files(vec![Some(DeletionFile::new(
+ "file:/tmp/a.dv".to_string(),
+ 0,
+ 0,
+ Some(3),
+ ))])
+ .with_raw_convertible(true)
+ .build()
+ .unwrap();
+ assert_eq!(s.merged_row_count(), Some(7));
+
+ let unknown = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![file("a", 10, None)])
+ .with_data_deletion_files(vec![Some(DeletionFile::new(
+ "file:/tmp/a.dv".to_string(),
+ 0,
+ 0,
+ None,
+ ))])
+ .with_raw_convertible(true)
+ .build()
+ .unwrap();
+ assert_eq!(unknown.merged_row_count(), None);
+ }
+}
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 5493938..7fe4156 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -76,9 +76,11 @@ impl<'a> TableRead<'a> {
let core_options = CoreOptions::new(self.table.schema.options());
let merge_engine = core_options.merge_engine()?;
- // PK table with Deduplicate engine: splits containing level-0 files
- // need KeyValueFileReader for sort-merge dedup; splits with only
- // compacted files (level > 0) can use the faster DataFileReader.
+ // PK table with Deduplicate engine: splits that may hold multiple
+ // versions of a key need KeyValueFileReader for sort-merge dedup;
+ // splits marked raw convertible by scan planning — and all compacted
+ // files of deletion-vector tables, where DVs mask stale versions —
+ // use the faster DataFileReader.
if has_primary_keys
&& matches!(
merge_engine,
@@ -95,8 +97,12 @@ impl<'a> TableRead<'a> {
}
}
- /// Read PK table with Deduplicate engine: level-0 splits go through
- /// KeyValueFileReader for sort-merge dedup, compacted splits use
DataFileReader.
+ /// Read PK table with Deduplicate engine: splits marked raw convertible
+ /// by scan planning (mirrors Java `DataSplit#convertToRawFiles`) use the
+ /// faster DataFileReader; the rest go through KeyValueFileReader for
+ /// sort-merge dedup. Deletion-vector tables are exempt: their stale
+ /// versions are masked by DVs, and KeyValueFileReader does not support
+ /// DVs, so they keep the plain level-0 dispatch.
fn read_pk(
&self,
data_splits: &[DataSplit],
@@ -106,10 +112,15 @@ impl<'a> TableRead<'a> {
return self.read_kv(data_splits, core_options);
}
+ // Deletion-vector tables read raw by design: stale versions of a key
+ // are masked by DVs, not merged, and KeyValueFileReader does not
+ // support DVs. Keep the plain level-0 dispatch for them.
+ let dv_enabled = core_options.deletion_vectors_enabled();
+
let mut kv_splits = Vec::new();
let mut raw_splits = Vec::new();
for split in data_splits {
- if split.data_files().iter().any(|f| f.level == 0) {
+ if pk_split_needs_merge(split, dv_enabled) {
kv_splits.push(split.clone());
} else {
raw_splits.push(split.clone());
@@ -192,3 +203,92 @@ impl<'a> TableRead<'a> {
)
}
}
+
+/// Whether a primary-key split must go through the sort-merge reader.
+///
+/// Mirrors Java `PrimaryKeyTableRawFileSplitReadProvider#match`: a raw read
+/// needs the split marked raw convertible AND a known `delete_row_count` on
+/// every file. Legacy files without the stat may hide delete rows — scan
+/// planning treats the missing stat as "no deletes" for compatibility, so the
+/// read side must fall back to the merge reader, which drops them.
+///
+/// Deletion-vector tables keep the plain level-0 dispatch: stale versions are
+/// masked by DVs and KeyValueFileReader does not support DVs.
+fn pk_split_needs_merge(split: &DataSplit, dv_enabled: bool) -> bool {
+ if dv_enabled {
+ return split.data_files().iter().any(|f| f.level == 0);
+ }
+ !split.raw_convertible()
+ || split
+ .data_files()
+ .iter()
+ .any(|f| f.delete_row_count.is_none())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{BinaryRow, DataFileMeta};
+ use crate::table::source::DataSplitBuilder;
+
+ fn file(name: &str, level: i32, delete_row_count: Option<i64>) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count: 10,
+ 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,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count,
+ embedded_index: None,
+ first_row_id: None,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ fn split(files: Vec<DataFileMeta>, raw_convertible: bool) -> DataSplit {
+ DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .with_raw_convertible(raw_convertible)
+ .build()
+ .unwrap()
+ }
+
+ #[test]
+ fn test_pk_split_needs_merge_routing() {
+ // Raw convertible with known delete counts: raw read.
+ let raw = split(vec![file("a", 5, Some(0))], true);
+ assert!(!pk_split_needs_merge(&raw, false));
+
+ // Not raw convertible: merge read.
+ let merge = split(vec![file("a", 5, Some(0))], false);
+ assert!(pk_split_needs_merge(&merge, false));
+
+ // Raw convertible but a legacy file lacks delete_row_count: the file
+ // may hide delete rows, so it must go through the merge reader.
+ let legacy = split(vec![file("a", 5, None)], true);
+ assert!(pk_split_needs_merge(&legacy, false));
+
+ // Deletion-vector tables dispatch on level 0 only.
+ let dv_l0 = split(vec![file("a", 0, None)], false);
+ assert!(pk_split_needs_merge(&dv_l0, true));
+ let dv_compacted = split(vec![file("a", 5, None)], false);
+ assert!(!pk_split_needs_merge(&dv_compacted, true));
+ }
+}
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 273a5c9..3135e77 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -35,6 +35,9 @@ use crate::spec::{
TimeTravelSelector,
};
use crate::table::bin_pack::split_for_batch;
+use crate::table::merge_tree_split_generator::{
+ merge_tree_split_for_batch, KeyComparator, SplitGroup,
+};
use crate::table::source::{
any_range_overlaps_file, intersect_ranges_with_file, merge_row_ranges,
DataSplit,
DataSplitBuilder, DeletionFile, PartitionBucket, Plan, RowRange,
@@ -413,16 +416,13 @@ impl<'a> TableScan<'a> {
/// Apply a limit-pushdown hint to the generated splits.
///
- /// Iterates through splits and accumulates `merged_row_count()` until the
- /// limit hint is reached. Returns only the splits likely needed to satisfy
- /// that hint.
- ///
- /// This does not guarantee an exact final row count. If a split's
- /// `merged_row_count()` is `None` (for example because of unknown deletion
- /// cardinality), that split is kept even though its contribution to the
- /// limit is unknown. Planning may still stop early later if the
- /// accumulated known `merged_row_count()` reaches the limit, and the
- /// caller or query engine must enforce the final LIMIT.
+ /// Mirrors Java `DataTableBatchScan#applyPushDownLimit`: splits whose
+ /// `merged_row_count()` is unknown (for example merge-needed PK splits or
+ /// unknown deletion cardinality) are skipped — they contribute an unknown
+ /// number of rows, so they cannot help satisfy the limit. Pruning is
+ /// committed only once the accumulated known row count reaches the limit;
+ /// if it never does, the original split list is returned unchanged. The
+ /// caller or query engine must still enforce the final LIMIT.
fn apply_limit_pushdown(&self, splits: Vec<DataSplit>) -> Vec<DataSplit> {
let limit = match self.limit {
Some(l) => l,
@@ -439,22 +439,17 @@ impl<'a> TableScan<'a> {
let mut limited_splits = Vec::new();
let mut scanned_row_count: i64 = 0;
- for split in splits {
- match split.merged_row_count() {
- Some(merged_count) => {
- limited_splits.push(split);
- scanned_row_count += merged_count;
- if scanned_row_count >= limit as i64 {
- return limited_splits;
- }
- }
- None => {
- limited_splits.push(split);
+ for split in &splits {
+ if let Some(merged_count) = split.merged_row_count() {
+ limited_splits.push(split.clone());
+ scanned_row_count += merged_count;
+ if scanned_row_count >= limit as i64 {
+ return limited_splits;
}
}
}
- limited_splits
+ splits
}
/// Read all manifest entries from a snapshot, applying filters and
merging.
@@ -620,6 +615,24 @@ impl<'a> TableScan<'a> {
None
};
+ // Primary-key tables must keep key-overlapping files in one split so
the
+ // sort-merge reader sees every version of a key. The comparator
decodes
+ // the trimmed-PK min/max keys written by the kv writer.
+ //
+ // Deletion-vector and first-row tables read without merging (stale
rows
+ // are masked by DVs / level-0 is skipped), so they keep plain
size-based
+ // packing like Java's MergeTreeSplitGenerator fast path.
+ let read_merges_overlapping_keys =
!core_options.deletion_vectors_enabled()
+ && !matches!(
+ core_options.merge_engine(),
+ Ok(crate::spec::MergeEngine::FirstRow)
+ );
+ let pk_comparator = if read_merges_overlapping_keys {
+ KeyComparator::from_table_schema(self.table.schema())
+ } else {
+ None
+ };
+
// Read deletion vector index manifest once (like Java generateSplits
/ scanDvIndex).
let (deletion_files_map, effective_row_ranges) =
if let Some(index_manifest_name) = snapshot.index_manifest() {
@@ -670,7 +683,7 @@ impl<'a> TableScan<'a> {
// Data-evolution tables merge overlapping row-id groups
column-wise during read.
// Keep that split boundary intact and only bin-pack single-file
groups.
// Apply group-level predicate filtering after grouping by row_id
range.
- let file_groups: Vec<Vec<DataFileMeta>> = if
data_evolution_enabled {
+ let file_groups: Vec<SplitGroup> = if data_evolution_enabled {
let row_id_groups = group_by_overlapping_row_id(data_files);
// Filter groups by merged stats before splitting.
@@ -705,20 +718,60 @@ impl<'a> TableScan<'a> {
let mut result = Vec::new();
for group in multis {
- result.push(group);
+ // Files sharing a row-id range hold column slices of the
+ // same logical rows; physical counts overcount them
+ // (Java DataEvolutionSplitGenerator: not raw convertible).
+ result.push(SplitGroup {
+ files: group,
+ raw_convertible: false,
+ });
}
let single_files: Vec<DataFileMeta> =
singles.into_iter().flatten().collect();
for file_group in split_for_batch(single_files,
target_split_size, open_file_cost) {
- result.push(file_group);
+ result.push(SplitGroup {
+ files: file_group,
+ raw_convertible: true,
+ });
}
result
+ } else if let Some(ref comparator) = pk_comparator {
+ // Merge-tree path: keep key-overlapping files in one split and
+ // mark which splits the sort-merge reader can skip (mirrors
+ // Java MergeTreeSplitGenerator#splitForBatch). Only engines
+ // whose writer deduplicates at flush guarantee a file never
+ // holds two rows of one key, so only they may mark groups raw
+ // convertible; see merge_tree_split_for_batch. (First-row
+ // tables do not take this path today, but its writer dedups
+ // too, so keep the gate accurate.)
+ let file_keys_unique = matches!(
+ core_options.merge_engine(),
+ Ok(crate::spec::MergeEngine::Deduplicate)
+ | Ok(crate::spec::MergeEngine::FirstRow)
+ );
+ merge_tree_split_for_batch(
+ data_files,
+ comparator,
+ target_split_size,
+ open_file_cost,
+ file_keys_unique,
+ )
} else {
split_for_batch(data_files, target_split_size, open_file_cost)
+ .into_iter()
+ .map(|files| SplitGroup {
+ files,
+ raw_convertible: true,
+ })
+ .collect()
};
- for file_group in file_groups {
+ for group in file_groups {
+ let SplitGroup {
+ files: file_group,
+ raw_convertible,
+ } = group;
let data_deletion_files =
per_bucket_deletion_map.map(|per_bucket| {
file_group
.iter()
@@ -748,7 +801,8 @@ impl<'a> TableScan<'a> {
.with_bucket(bucket)
.with_bucket_path(bucket_path.clone())
.with_total_buckets(total_buckets)
- .with_data_files(file_group);
+ .with_data_files(file_group)
+ .with_raw_convertible(raw_convertible);
if let Some(files) = data_deletion_files {
builder = builder.with_data_deletion_files(files);
}
@@ -1008,8 +1062,11 @@ mod tests {
assert!(pruned.is_empty());
}
+ /// Java semantics: unknown-count splits are skipped — they cannot prove
+ /// progress toward the limit — and pruning commits once the counted
+ /// splits alone cover the limit.
#[test]
- fn test_apply_limit_pushdown_keeps_unknown_merged_row_count() {
+ fn test_apply_limit_pushdown_skips_unknown_merged_row_count() {
let table = limit_test_table();
let scan = TableScan::new(&table, None, vec![], None, Some(3), None);
let splits = vec![
@@ -1020,12 +1077,58 @@ mod tests {
let pruned = scan.apply_limit_pushdown(splits);
+ assert_eq!(split_file_names(&pruned), vec!["a.parquet", "c.parquet"]);
+ }
+
+ /// When counted splits never reach the limit, the original split list is
+ /// returned unchanged (mirrors Java `applyPushDownLimit`).
+ #[test]
+ fn test_apply_limit_pushdown_returns_all_when_limit_not_reached() {
+ let table = limit_test_table();
+ let scan = TableScan::new(&table, None, vec![], None, Some(100), None);
+ let splits = vec![
+ limit_test_split("a.parquet", 2),
+ limit_test_split_with_unknown_merged_row_count("b.parquet", 4),
+ limit_test_split("c.parquet", 3),
+ ];
+
+ let pruned = scan.apply_limit_pushdown(splits);
+
assert_eq!(
split_file_names(&pruned),
vec!["a.parquet", "b.parquet", "c.parquet"]
);
}
+ /// A non-raw-convertible split (merge-needed PK split) has an unknown
+ /// merged row count: its physical rows overcount merged versions, so it
+ /// must not satisfy the limit on its own.
+ #[test]
+ fn test_apply_limit_pushdown_treats_merge_splits_as_unknown() {
+ let table = limit_test_table();
+ let scan = TableScan::new(&table, None, vec![], None, Some(3), None);
+
+ let mut merge_file = test_data_file_meta(Vec::new(), Vec::new(),
Vec::new(), 10);
+ merge_file.file_name = "merge.parquet".to_string();
+ let merge_split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/merge.parquet".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![merge_file])
+ .with_raw_convertible(false)
+ .build()
+ .unwrap();
+ assert_eq!(merge_split.merged_row_count(), None);
+
+ let splits = vec![merge_split, limit_test_split("a.parquet", 3)];
+ let pruned = scan.apply_limit_pushdown(splits);
+
+ // The merge split is skipped; only the counted split commits pruning.
+ assert_eq!(split_file_names(&pruned), vec!["a.parquet"]);
+ }
+
#[test]
fn test_first_row_skips_level_zero_by_default() {
assert!(should_skip_level_zero_for_scan(
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index fea6ff7..d8add25 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -2567,4 +2567,139 @@ mod tests {
assert_eq!(ids, vec![1]);
assert_eq!(values, vec![20]);
}
+
+ fn tiny_split_pk_table(file_io: &FileIO, table_path: &str) -> Table {
+ Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_tiny_split_pk"),
+ table_path.to_string(),
+ pk_changelog_schema(&[
+ ("source.split.target-size", "1b"),
+ ("source.split.open-file-cost", "1b"),
+ ]),
+ None,
+ )
+ }
+
+ async fn commit_one_batch(table: &Table, ids: Vec<i32>, values: Vec<i32>) {
+ let mut tw = TableWrite::new(table, "test-user".to_string()).unwrap();
+ tw.write_arrow_batch(&make_batch(ids, values))
+ .await
+ .unwrap();
+ let msgs = tw.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(msgs)
+ .await
+ .unwrap();
+ }
+
+ async fn read_id_value_rows(table: &Table) -> Vec<(i32, i32)> {
+ let rb = table.new_read_builder();
+ let plan = rb.new_scan().plan().await.unwrap();
+ let read = rb.new_read().unwrap();
+ let batches: Vec<RecordBatch> =
+
futures::TryStreamExt::try_collect(read.to_arrow(plan.splits()).unwrap())
+ .await
+ .unwrap();
+ let mut rows: Vec<(i32, i32)> = batches
+ .iter()
+ .flat_map(|b| {
+ let ids =
b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
+ let values =
b.column(1).as_any().downcast_ref::<Int32Array>().unwrap();
+ (0..b.num_rows()).map(|i| (ids.value(i), values.value(i)))
+ })
+ .collect();
+ rows.sort_unstable();
+ rows
+ }
+
+ /// Regression test: three commits of the same primary key produce three
+ /// key-overlapping files. Even with a 1-byte split target they must stay
+ /// in one split so the sort-merge reader merges them, instead of each
+ /// split emitting its own (stale) version of the row.
+ #[tokio::test]
+ async fn
test_pk_plan_keeps_overlapping_files_in_one_split_under_tiny_target() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_tiny_split_overlap";
+ setup_dirs(&file_io, table_path).await;
+ let table = tiny_split_pk_table(&file_io, table_path);
+
+ commit_one_batch(&table, vec![1], vec![10]).await;
+ commit_one_batch(&table, vec![1], vec![20]).await;
+ commit_one_batch(&table, vec![1], vec![30]).await;
+
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ assert_eq!(
+ plan.splits().len(),
+ 1,
+ "overlapping files must share a split"
+ );
+ assert_eq!(plan.splits()[0].data_files().len(), 3);
+
+ assert_eq!(read_id_value_rows(&table).await, vec![(1, 30)]);
+ }
+
+ /// Files with disjoint key ranges may still be distributed across splits
+ /// for parallelism when the split target is small.
+ #[tokio::test]
+ async fn test_pk_plan_separates_disjoint_files_under_tiny_target() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_tiny_split_disjoint";
+ setup_dirs(&file_io, table_path).await;
+ let table = tiny_split_pk_table(&file_io, table_path);
+
+ commit_one_batch(&table, vec![1], vec![10]).await;
+ commit_one_batch(&table, vec![2], vec![20]).await;
+ commit_one_batch(&table, vec![3], vec![30]).await;
+
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ assert_eq!(
+ plan.splits().len(),
+ 3,
+ "disjoint files keep split parallelism"
+ );
+
+ assert_eq!(
+ read_id_value_rows(&table).await,
+ vec![(1, 10), (2, 20), (3, 30)]
+ );
+ }
+
+ /// Append-only tables have no primary keys (and empty min/max keys); they
+ /// must keep using plain file-level bin packing instead of degrading to a
+ /// single all-files section.
+ #[tokio::test]
+ async fn
test_append_table_plan_uses_file_level_bin_pack_under_tiny_target() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_tiny_split_append";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "1")
+ .option("bucket-key", "id")
+ .option("source.split.target-size", "1b")
+ .option("source.split.open-file-cost", "1b")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_tiny_split_append"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ commit_one_batch(&table, vec![1], vec![10]).await;
+ commit_one_batch(&table, vec![2], vec![20]).await;
+ commit_one_batch(&table, vec![3], vec![30]).await;
+
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ assert_eq!(
+ plan.splits().len(),
+ 3,
+ "append tables keep file-level bin pack"
+ );
+ }
}