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 9a2de856 feat(table): read materialized deletion-vector PK splits
(#580)
9a2de856 is described below
commit 9a2de8562452744ce1efbf2227d4b51920690933
Author: shyjsarah <[email protected]>
AuthorDate: Wed Jul 22 11:18:45 2026 +0800
feat(table): read materialized deletion-vector PK splits (#580)
---
crates/paimon/src/table/read_builder.rs | 135 ++++++++++++++++++++++++++------
crates/paimon/src/table/source.rs | 44 +++++++++++
crates/paimon/src/table/table_read.rs | 57 ++++++++++----
docs/src/sql.md | 14 +++-
4 files changed, 207 insertions(+), 43 deletions(-)
diff --git a/crates/paimon/src/table/read_builder.rs
b/crates/paimon/src/table/read_builder.rs
index d959b0bb..c75ee528 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -639,14 +639,17 @@ mod tests {
}
use crate::catalog::Identifier;
+ use crate::deletion_vector::DeletionVector;
use crate::io::FileIOBuilder;
use crate::spec::{
BinaryRow, DataField, DataType, IntType, Predicate, PredicateBuilder,
Schema, TableSchema,
VarCharType,
};
- use crate::table::{query_auth_table, DataSplitBuilder, Table};
+ use crate::table::{query_auth_table, DataSplitBuilder, DeletionFile,
Table};
use arrow_array::{Int32Array, RecordBatch};
+ use bytes::Bytes;
use futures::TryStreamExt;
+ use roaring::RoaringBitmap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -675,6 +678,31 @@ mod tests {
.collect()
}
+ async fn write_test_deletion_file(
+ file_io: &crate::io::FileIO,
+ path: &str,
+ deleted_rows: &[u32],
+ ) -> DeletionFile {
+ let bitmap = deleted_rows.iter().copied().collect::<RoaringBitmap>();
+ let bytes = DeletionVector::from_bitmap(bitmap)
+ .serialize_to_bytes()
+ .unwrap();
+ let bitmap_length =
i32::from_be_bytes(bytes[0..4].try_into().unwrap());
+ file_io
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from(bytes))
+ .await
+ .unwrap();
+
+ DeletionFile::new(
+ path.to_string(),
+ 0,
+ bitmap_length as i64,
+ Some(deleted_rows.len() as i64),
+ )
+ }
+
fn simple_table() -> Table {
let file_io = FileIOBuilder::new("file").build().unwrap();
let table_schema = TableSchema::new(
@@ -695,7 +723,7 @@ mod tests {
)
}
- fn partial_update_dv_pk_table() -> Table {
+ fn dv_pk_table(table_path: &str, merge_engine: &str) -> Table {
let file_io = FileIOBuilder::new("file").build().unwrap();
let table_schema = TableSchema::new(
0,
@@ -703,7 +731,7 @@ mod tests {
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
- .option("merge-engine", "partial-update")
+ .option("merge-engine", merge_engine)
.option("deletion-vectors.enabled", "true")
.build()
.unwrap(),
@@ -711,12 +739,54 @@ mod tests {
Table::new(
file_io,
Identifier::new("default", "partial_update_dv_t"),
- "/tmp/test-partial-update-dv-read-builder".to_string(),
+ table_path.to_string(),
table_schema,
None,
)
}
+ async fn read_compacted_dv_table(merge_engine: &str) -> Vec<RecordBatch> {
+ let tempdir = tempdir().unwrap();
+ let table_path = local_file_path(tempdir.path());
+ let bucket_dir = tempdir.path().join("bucket-0");
+ let index_dir = tempdir.path().join("index");
+ fs::create_dir_all(&bucket_dir).unwrap();
+ fs::create_dir_all(&index_dir).unwrap();
+
+ let data_path = bucket_dir.join("data.parquet");
+ write_int_parquet_file(
+ &data_path,
+ vec![("id", vec![1, 2, 3]), ("value", vec![10, 20, 30])],
+ None,
+ );
+ let file_size = fs::metadata(&data_path).unwrap().len() as i64;
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let deletion_file =
+ write_test_deletion_file(&file_io,
&local_file_path(&index_dir.join("dv")), &[1]).await;
+
+ let table = dv_pk_table(&table_path, merge_engine);
+ let mut data_file =
+ test_data_file::<crate::spec::DataFileMeta>("data.parquet", 3,
file_size);
+ data_file.delete_row_count = Some(0);
+ let 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])
+ .with_data_deletion_files(vec![Some(deletion_file)])
+ .build()
+ .unwrap();
+
+ TableRead::new(&table, table.schema().fields().to_vec(), Vec::new())
+ .to_arrow(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap()
+ }
+
#[test]
fn test_read_fails_closed_when_query_auth_enabled() {
let table = query_auth_table();
@@ -1540,33 +1610,50 @@ mod tests {
}
#[tokio::test]
- async fn
test_direct_table_read_rejects_partial_update_with_deletion_vectors() {
- let table = partial_update_dv_pk_table();
+ async fn
test_direct_table_read_reads_compacted_partial_update_with_deletion_vectors() {
+ let batches = read_compacted_dv_table("partial-update").await;
+
+ assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
+ assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
+ }
+
+ #[tokio::test]
+ async fn
test_direct_table_read_reads_compacted_aggregation_with_deletion_vectors() {
+ let batches = read_compacted_dv_table("aggregation").await;
+
+ assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
+ assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
+ }
+
+ #[test]
+ fn test_direct_table_read_rejects_partial_update_dv_merge_on_read() {
+ let table = dv_pk_table(
+ "/tmp/test-partial-update-dv-merge-on-read",
+ "partial-update",
+ )
+ .copy_with_options(HashMap::from([(
+ "deletion-vectors.merge-on-read".to_string(),
+ "true".to_string(),
+ )]));
+ let mut data_file =
test_data_file::<crate::spec::DataFileMeta>("data.parquet", 1, 0);
+ data_file.delete_row_count = Some(0);
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(BinaryRow::new(0))
.with_bucket(0)
-
.with_bucket_path("/tmp/test-partial-update-dv-read-builder/bucket-0".to_string())
+
.with_bucket_path("/tmp/test-partial-update-dv-merge-on-read/bucket-0".to_string())
.with_total_buckets(1)
- .with_data_files(vec![test_data_file("data.parquet", 1, 0)])
-
.with_data_deletion_files(vec![Some(crate::table::source::DeletionFile::new(
-
"/tmp/test-partial-update-dv-read-builder/index/dv".to_string(),
- 0,
- 0,
- None,
- ))])
+ .with_data_files(vec![data_file])
.build()
.unwrap();
- let err = TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new())
- .to_arrow(&[split])
- .unwrap()
- .try_collect::<Vec<_>>()
- .await
- .unwrap_err();
- assert!(
- matches!(err, crate::Error::Unsupported { ref message } if
message.contains("deletion vectors")),
- "expected partial-update+DV read to fail fast with Unsupported,
got {err:?}"
- );
+ let result =
+ TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new()).to_arrow(&[split]);
+
+ assert!(matches!(
+ result,
+ Err(crate::Error::Unsupported { ref message })
+ if message.contains("merge-on-read")
+ ));
}
}
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index e852630f..a8d6b785 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -525,6 +525,20 @@ impl DataSplit {
self.raw_convertible
}
+ /// Whether this primary-key split is safe to read raw when deletion
+ /// vectors provide row-level filtering.
+ ///
+ /// This is deliberately stronger than [`Self::raw_convertible`]: direct
+ /// or deserialized splits may bypass scan planning, so every file must
+ /// also be compacted and known not to contain retract rows.
+ pub(crate) fn is_fully_materialized_pk_dv(&self) -> bool {
+ self.raw_convertible
+ && self
+ .data_files
+ .iter()
+ .all(|file| file.level != 0 && file.delete_row_count ==
Some(0))
+ }
+
/// 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
@@ -1318,6 +1332,36 @@ mod tests {
assert_eq!(s.merged_row_count(), Some(15));
}
+ #[test]
+ fn test_fully_materialized_pk_dv_requires_compacted_files() {
+ let mut level_zero = file("a", 10, None);
+ level_zero.level = 0;
+ level_zero.delete_row_count = Some(0);
+
+ assert!(!split(vec![level_zero], true).is_fully_materialized_pk_dv());
+ }
+
+ #[test]
+ fn test_fully_materialized_pk_dv_requires_raw_convertible() {
+ let mut compacted = file("a", 10, None);
+ compacted.delete_row_count = Some(0);
+
+ assert!(!split(vec![compacted], false).is_fully_materialized_pk_dv());
+ }
+
+ #[test]
+ fn test_fully_materialized_pk_dv_requires_known_zero_delete_rows() {
+ let unknown = file("unknown", 10, None);
+ let mut retracts = file("retracts", 10, None);
+ retracts.delete_row_count = Some(1);
+ let mut materialized = file("materialized", 10, None);
+ materialized.delete_row_count = Some(0);
+
+ assert!(!split(vec![unknown], true).is_fully_materialized_pk_dv());
+ assert!(!split(vec![retracts], true).is_fully_materialized_pk_dv());
+ assert!(split(vec![materialized], true).is_fully_materialized_pk_dv());
+ }
+
#[test]
fn test_data_file_path_prefers_external_path() {
let mut f = file("data-0.parquet", 10, None);
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 2a1f70cd..6c57f6b6 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -369,13 +369,9 @@ impl<'a> PaimonTableRead<'a> {
core_options.ensure_read_authorized()?;
let merge_engine = core_options.merge_engine()?;
- // 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.
- // PartialUpdate / Aggregation always go through KeyValueFileReader so
- // that per-key materialization can run on the read side.
+ // Route supported PK merge engines through the split-aware reader.
+ // Deduplicate may mix raw and KV splits. Partial-update and
aggregation
+ // use KV reads normally, but fully materialized DV plans can read raw.
if has_primary_keys
&& matches!(
merge_engine,
@@ -395,28 +391,57 @@ impl<'a> PaimonTableRead<'a> {
/// Read PK table. For `Deduplicate`, 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. `PartialUpdate` and `Aggregation` always go
- /// through KeyValueFileReader because their merge semantics require
per-key
- /// materialization even for compacted runs.
+ /// dedup. A fully materialized deletion-vector plan for `PartialUpdate` or
+ /// `Aggregation` can also be read raw because DVs already mask stale rows.
+ /// Plans that still need any per-key merge fail closed because mixing raw
+ /// and merged outputs would produce incorrect results.
fn read_pk(
&self,
data_splits: &[DataSplit],
core_options: &CoreOptions,
) -> crate::Result<ArrowRecordBatchStream> {
+ let merge_engine = core_options.merge_engine()?;
+ let dv_enabled = core_options.deletion_vectors_enabled();
if matches!(
- core_options.merge_engine()?,
+ merge_engine,
MergeEngine::PartialUpdate | MergeEngine::Aggregation
- ) {
+ ) && !dv_enabled
+ {
return self.read_kv(data_splits, core_options);
}
+ if matches!(
+ merge_engine,
+ MergeEngine::PartialUpdate | MergeEngine::Aggregation
+ ) {
+ let merge_engine_name = match merge_engine {
+ MergeEngine::PartialUpdate => "partial-update",
+ MergeEngine::Aggregation => "aggregation",
+ _ => unreachable!("guarded by partial-update/aggregation
match"),
+ };
+ if core_options.deletion_vectors_merge_on_read() {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "merge-engine={merge_engine_name} with
deletion-vectors.merge-on-read=true is not supported"
+ ),
+ });
+ }
+ if !data_splits
+ .iter()
+ .all(DataSplit::is_fully_materialized_pk_dv)
+ {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "merge-engine={merge_engine_name} with deletion
vectors can only read fully materialized compacted splits"
+ ),
+ });
+ }
+ return self.read_raw(data_splits);
+ }
+
// 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 {
diff --git a/docs/src/sql.md b/docs/src/sql.md
index ac3d0ee9..ac810727 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1889,6 +1889,13 @@ an existing partial-update table, `ignore-delete` cannot
be changed back to
`false`. Advanced partial-update features such as sequence groups, partial
aggregation, and remove-record-on-delete are not supported.
+Rust can read fully materialized compacted files from deletion-vector-enabled
+partial-update and aggregation tables. Every split must be raw-convertible,
+every file must have a known zero delete-row count, and
+`deletion-vectors.merge-on-read` must remain disabled. Writing these table
+combinations and reading plans that still require per-key merging are not
+supported.
+
Rust currently supports `merge-engine=aggregation` in basic mode only. It works
with fixed buckets and ordinary dynamic buckets (`'bucket' = '-1'`) when the
primary key includes all partition columns. It supports per-field aggregate
@@ -1900,9 +1907,10 @@ Sequence fields are always merged with `last_value`.
Defining
validation.
This is not full Java feature parity. Aggregation tables do not support retract
-rows (`DELETE` / `UPDATE_BEFORE`), deletion vectors, cross-partition dynamic
-bucket writes, or advanced aggregation options such as `ignore-retract`,
-`distinct`, `nested-key`, `count-limit`, and sequence groups.
+rows (`DELETE` / `UPDATE_BEFORE`), deletion-vector writes or merge-on-read
+plans, cross-partition dynamic bucket writes, or advanced aggregation options
+such as `ignore-retract`, `distinct`, `nested-key`, `count-limit`, and sequence
+groups.
### Global Index Options