Skip to content

Commit 9a2de85

Browse files
authored
feat(table): read materialized deletion-vector PK splits (#580)
1 parent 308d5b0 commit 9a2de85

4 files changed

Lines changed: 207 additions & 43 deletions

File tree

crates/paimon/src/table/read_builder.rs

Lines changed: 111 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -639,14 +639,17 @@ mod tests {
639639
}
640640

641641
use crate::catalog::Identifier;
642+
use crate::deletion_vector::DeletionVector;
642643
use crate::io::FileIOBuilder;
643644
use crate::spec::{
644645
BinaryRow, DataField, DataType, IntType, Predicate, PredicateBuilder, Schema, TableSchema,
645646
VarCharType,
646647
};
647-
use crate::table::{query_auth_table, DataSplitBuilder, Table};
648+
use crate::table::{query_auth_table, DataSplitBuilder, DeletionFile, Table};
648649
use arrow_array::{Int32Array, RecordBatch};
650+
use bytes::Bytes;
649651
use futures::TryStreamExt;
652+
use roaring::RoaringBitmap;
650653
use std::collections::{HashMap, HashSet};
651654
use std::fs;
652655
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -675,6 +678,31 @@ mod tests {
675678
.collect()
676679
}
677680

681+
async fn write_test_deletion_file(
682+
file_io: &crate::io::FileIO,
683+
path: &str,
684+
deleted_rows: &[u32],
685+
) -> DeletionFile {
686+
let bitmap = deleted_rows.iter().copied().collect::<RoaringBitmap>();
687+
let bytes = DeletionVector::from_bitmap(bitmap)
688+
.serialize_to_bytes()
689+
.unwrap();
690+
let bitmap_length = i32::from_be_bytes(bytes[0..4].try_into().unwrap());
691+
file_io
692+
.new_output(path)
693+
.unwrap()
694+
.write(Bytes::from(bytes))
695+
.await
696+
.unwrap();
697+
698+
DeletionFile::new(
699+
path.to_string(),
700+
0,
701+
bitmap_length as i64,
702+
Some(deleted_rows.len() as i64),
703+
)
704+
}
705+
678706
fn simple_table() -> Table {
679707
let file_io = FileIOBuilder::new("file").build().unwrap();
680708
let table_schema = TableSchema::new(
@@ -695,28 +723,70 @@ mod tests {
695723
)
696724
}
697725

698-
fn partial_update_dv_pk_table() -> Table {
726+
fn dv_pk_table(table_path: &str, merge_engine: &str) -> Table {
699727
let file_io = FileIOBuilder::new("file").build().unwrap();
700728
let table_schema = TableSchema::new(
701729
0,
702730
&Schema::builder()
703731
.column("id", DataType::Int(IntType::new()))
704732
.column("value", DataType::Int(IntType::new()))
705733
.primary_key(["id"])
706-
.option("merge-engine", "partial-update")
734+
.option("merge-engine", merge_engine)
707735
.option("deletion-vectors.enabled", "true")
708736
.build()
709737
.unwrap(),
710738
);
711739
Table::new(
712740
file_io,
713741
Identifier::new("default", "partial_update_dv_t"),
714-
"/tmp/test-partial-update-dv-read-builder".to_string(),
742+
table_path.to_string(),
715743
table_schema,
716744
None,
717745
)
718746
}
719747

748+
async fn read_compacted_dv_table(merge_engine: &str) -> Vec<RecordBatch> {
749+
let tempdir = tempdir().unwrap();
750+
let table_path = local_file_path(tempdir.path());
751+
let bucket_dir = tempdir.path().join("bucket-0");
752+
let index_dir = tempdir.path().join("index");
753+
fs::create_dir_all(&bucket_dir).unwrap();
754+
fs::create_dir_all(&index_dir).unwrap();
755+
756+
let data_path = bucket_dir.join("data.parquet");
757+
write_int_parquet_file(
758+
&data_path,
759+
vec![("id", vec![1, 2, 3]), ("value", vec![10, 20, 30])],
760+
None,
761+
);
762+
let file_size = fs::metadata(&data_path).unwrap().len() as i64;
763+
let file_io = FileIOBuilder::new("file").build().unwrap();
764+
let deletion_file =
765+
write_test_deletion_file(&file_io, &local_file_path(&index_dir.join("dv")), &[1]).await;
766+
767+
let table = dv_pk_table(&table_path, merge_engine);
768+
let mut data_file =
769+
test_data_file::<crate::spec::DataFileMeta>("data.parquet", 3, file_size);
770+
data_file.delete_row_count = Some(0);
771+
let split = DataSplitBuilder::new()
772+
.with_snapshot(1)
773+
.with_partition(BinaryRow::new(0))
774+
.with_bucket(0)
775+
.with_bucket_path(local_file_path(&bucket_dir))
776+
.with_total_buckets(1)
777+
.with_data_files(vec![data_file])
778+
.with_data_deletion_files(vec![Some(deletion_file)])
779+
.build()
780+
.unwrap();
781+
782+
TableRead::new(&table, table.schema().fields().to_vec(), Vec::new())
783+
.to_arrow(&[split])
784+
.unwrap()
785+
.try_collect::<Vec<_>>()
786+
.await
787+
.unwrap()
788+
}
789+
720790
#[test]
721791
fn test_read_fails_closed_when_query_auth_enabled() {
722792
let table = query_auth_table();
@@ -1540,33 +1610,50 @@ mod tests {
15401610
}
15411611

15421612
#[tokio::test]
1543-
async fn test_direct_table_read_rejects_partial_update_with_deletion_vectors() {
1544-
let table = partial_update_dv_pk_table();
1613+
async fn test_direct_table_read_reads_compacted_partial_update_with_deletion_vectors() {
1614+
let batches = read_compacted_dv_table("partial-update").await;
1615+
1616+
assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
1617+
assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
1618+
}
1619+
1620+
#[tokio::test]
1621+
async fn test_direct_table_read_reads_compacted_aggregation_with_deletion_vectors() {
1622+
let batches = read_compacted_dv_table("aggregation").await;
1623+
1624+
assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
1625+
assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
1626+
}
1627+
1628+
#[test]
1629+
fn test_direct_table_read_rejects_partial_update_dv_merge_on_read() {
1630+
let table = dv_pk_table(
1631+
"/tmp/test-partial-update-dv-merge-on-read",
1632+
"partial-update",
1633+
)
1634+
.copy_with_options(HashMap::from([(
1635+
"deletion-vectors.merge-on-read".to_string(),
1636+
"true".to_string(),
1637+
)]));
1638+
let mut data_file = test_data_file::<crate::spec::DataFileMeta>("data.parquet", 1, 0);
1639+
data_file.delete_row_count = Some(0);
15451640
let split = DataSplitBuilder::new()
15461641
.with_snapshot(1)
15471642
.with_partition(BinaryRow::new(0))
15481643
.with_bucket(0)
1549-
.with_bucket_path("/tmp/test-partial-update-dv-read-builder/bucket-0".to_string())
1644+
.with_bucket_path("/tmp/test-partial-update-dv-merge-on-read/bucket-0".to_string())
15501645
.with_total_buckets(1)
1551-
.with_data_files(vec![test_data_file("data.parquet", 1, 0)])
1552-
.with_data_deletion_files(vec![Some(crate::table::source::DeletionFile::new(
1553-
"/tmp/test-partial-update-dv-read-builder/index/dv".to_string(),
1554-
0,
1555-
0,
1556-
None,
1557-
))])
1646+
.with_data_files(vec![data_file])
15581647
.build()
15591648
.unwrap();
1560-
let err = TableRead::new(&table, table.schema().fields().to_vec(), Vec::new())
1561-
.to_arrow(&[split])
1562-
.unwrap()
1563-
.try_collect::<Vec<_>>()
1564-
.await
1565-
.unwrap_err();
15661649

1567-
assert!(
1568-
matches!(err, crate::Error::Unsupported { ref message } if message.contains("deletion vectors")),
1569-
"expected partial-update+DV read to fail fast with Unsupported, got {err:?}"
1570-
);
1650+
let result =
1651+
TableRead::new(&table, table.schema().fields().to_vec(), Vec::new()).to_arrow(&[split]);
1652+
1653+
assert!(matches!(
1654+
result,
1655+
Err(crate::Error::Unsupported { ref message })
1656+
if message.contains("merge-on-read")
1657+
));
15711658
}
15721659
}

crates/paimon/src/table/source.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,20 @@ impl DataSplit {
525525
self.raw_convertible
526526
}
527527

528+
/// Whether this primary-key split is safe to read raw when deletion
529+
/// vectors provide row-level filtering.
530+
///
531+
/// This is deliberately stronger than [`Self::raw_convertible`]: direct
532+
/// or deserialized splits may bypass scan planning, so every file must
533+
/// also be compacted and known not to contain retract rows.
534+
pub(crate) fn is_fully_materialized_pk_dv(&self) -> bool {
535+
self.raw_convertible
536+
&& self
537+
.data_files
538+
.iter()
539+
.all(|file| file.level != 0 && file.delete_row_count == Some(0))
540+
}
541+
528542
/// Returns the deletion file for the data file at the given index, if any. `None` at that index means no deletion file.
529543
pub fn deletion_file_for_data_file_index(&self, index: usize) -> Option<&DeletionFile> {
530544
self.data_deletion_files
@@ -1318,6 +1332,36 @@ mod tests {
13181332
assert_eq!(s.merged_row_count(), Some(15));
13191333
}
13201334

1335+
#[test]
1336+
fn test_fully_materialized_pk_dv_requires_compacted_files() {
1337+
let mut level_zero = file("a", 10, None);
1338+
level_zero.level = 0;
1339+
level_zero.delete_row_count = Some(0);
1340+
1341+
assert!(!split(vec![level_zero], true).is_fully_materialized_pk_dv());
1342+
}
1343+
1344+
#[test]
1345+
fn test_fully_materialized_pk_dv_requires_raw_convertible() {
1346+
let mut compacted = file("a", 10, None);
1347+
compacted.delete_row_count = Some(0);
1348+
1349+
assert!(!split(vec![compacted], false).is_fully_materialized_pk_dv());
1350+
}
1351+
1352+
#[test]
1353+
fn test_fully_materialized_pk_dv_requires_known_zero_delete_rows() {
1354+
let unknown = file("unknown", 10, None);
1355+
let mut retracts = file("retracts", 10, None);
1356+
retracts.delete_row_count = Some(1);
1357+
let mut materialized = file("materialized", 10, None);
1358+
materialized.delete_row_count = Some(0);
1359+
1360+
assert!(!split(vec![unknown], true).is_fully_materialized_pk_dv());
1361+
assert!(!split(vec![retracts], true).is_fully_materialized_pk_dv());
1362+
assert!(split(vec![materialized], true).is_fully_materialized_pk_dv());
1363+
}
1364+
13211365
#[test]
13221366
fn test_data_file_path_prefers_external_path() {
13231367
let mut f = file("data-0.parquet", 10, None);

crates/paimon/src/table/table_read.rs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,9 @@ impl<'a> PaimonTableRead<'a> {
369369
core_options.ensure_read_authorized()?;
370370
let merge_engine = core_options.merge_engine()?;
371371

372-
// PK table with Deduplicate engine: splits that may hold multiple
373-
// versions of a key need KeyValueFileReader for sort-merge dedup;
374-
// splits marked raw convertible by scan planning — and all compacted
375-
// files of deletion-vector tables, where DVs mask stale versions —
376-
// use the faster DataFileReader.
377-
// PartialUpdate / Aggregation always go through KeyValueFileReader so
378-
// that per-key materialization can run on the read side.
372+
// Route supported PK merge engines through the split-aware reader.
373+
// Deduplicate may mix raw and KV splits. Partial-update and aggregation
374+
// use KV reads normally, but fully materialized DV plans can read raw.
379375
if has_primary_keys
380376
&& matches!(
381377
merge_engine,
@@ -395,28 +391,57 @@ impl<'a> PaimonTableRead<'a> {
395391
/// Read PK table. For `Deduplicate`, splits marked raw convertible by scan
396392
/// planning (mirrors Java `DataSplit#convertToRawFiles`) use the faster
397393
/// DataFileReader; the rest go through KeyValueFileReader for sort-merge
398-
/// dedup. Deletion-vector tables are exempt: their stale versions are
399-
/// masked by DVs, and KeyValueFileReader does not support DVs, so they keep
400-
/// the plain level-0 dispatch. `PartialUpdate` and `Aggregation` always go
401-
/// through KeyValueFileReader because their merge semantics require per-key
402-
/// materialization even for compacted runs.
394+
/// dedup. A fully materialized deletion-vector plan for `PartialUpdate` or
395+
/// `Aggregation` can also be read raw because DVs already mask stale rows.
396+
/// Plans that still need any per-key merge fail closed because mixing raw
397+
/// and merged outputs would produce incorrect results.
403398
fn read_pk(
404399
&self,
405400
data_splits: &[DataSplit],
406401
core_options: &CoreOptions,
407402
) -> crate::Result<ArrowRecordBatchStream> {
403+
let merge_engine = core_options.merge_engine()?;
404+
let dv_enabled = core_options.deletion_vectors_enabled();
408405
if matches!(
409-
core_options.merge_engine()?,
406+
merge_engine,
410407
MergeEngine::PartialUpdate | MergeEngine::Aggregation
411-
) {
408+
) && !dv_enabled
409+
{
412410
return self.read_kv(data_splits, core_options);
413411
}
414412

413+
if matches!(
414+
merge_engine,
415+
MergeEngine::PartialUpdate | MergeEngine::Aggregation
416+
) {
417+
let merge_engine_name = match merge_engine {
418+
MergeEngine::PartialUpdate => "partial-update",
419+
MergeEngine::Aggregation => "aggregation",
420+
_ => unreachable!("guarded by partial-update/aggregation match"),
421+
};
422+
if core_options.deletion_vectors_merge_on_read() {
423+
return Err(crate::Error::Unsupported {
424+
message: format!(
425+
"merge-engine={merge_engine_name} with deletion-vectors.merge-on-read=true is not supported"
426+
),
427+
});
428+
}
429+
if !data_splits
430+
.iter()
431+
.all(DataSplit::is_fully_materialized_pk_dv)
432+
{
433+
return Err(crate::Error::Unsupported {
434+
message: format!(
435+
"merge-engine={merge_engine_name} with deletion vectors can only read fully materialized compacted splits"
436+
),
437+
});
438+
}
439+
return self.read_raw(data_splits);
440+
}
441+
415442
// Deletion-vector tables read raw by design: stale versions of a key
416443
// are masked by DVs, not merged, and KeyValueFileReader does not
417444
// support DVs. Keep the plain level-0 dispatch for them.
418-
let dv_enabled = core_options.deletion_vectors_enabled();
419-
420445
let mut kv_splits = Vec::new();
421446
let mut raw_splits = Vec::new();
422447
for split in data_splits {

docs/src/sql.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,6 +1889,13 @@ an existing partial-update table, `ignore-delete` cannot be changed back to
18891889
`false`. Advanced partial-update features such as sequence groups, partial
18901890
aggregation, and remove-record-on-delete are not supported.
18911891

1892+
Rust can read fully materialized compacted files from deletion-vector-enabled
1893+
partial-update and aggregation tables. Every split must be raw-convertible,
1894+
every file must have a known zero delete-row count, and
1895+
`deletion-vectors.merge-on-read` must remain disabled. Writing these table
1896+
combinations and reading plans that still require per-key merging are not
1897+
supported.
1898+
18921899
Rust currently supports `merge-engine=aggregation` in basic mode only. It works
18931900
with fixed buckets and ordinary dynamic buckets (`'bucket' = '-1'`) when the
18941901
primary key includes all partition columns. It supports per-field aggregate
@@ -1900,9 +1907,10 @@ Sequence fields are always merged with `last_value`. Defining
19001907
validation.
19011908

19021909
This is not full Java feature parity. Aggregation tables do not support retract
1903-
rows (`DELETE` / `UPDATE_BEFORE`), deletion vectors, cross-partition dynamic
1904-
bucket writes, or advanced aggregation options such as `ignore-retract`,
1905-
`distinct`, `nested-key`, `count-limit`, and sequence groups.
1910+
rows (`DELETE` / `UPDATE_BEFORE`), deletion-vector writes or merge-on-read
1911+
plans, cross-partition dynamic bucket writes, or advanced aggregation options
1912+
such as `ignore-retract`, `distinct`, `nested-key`, `count-limit`, and sequence
1913+
groups.
19061914

19071915
### Global Index Options
19081916

0 commit comments

Comments
 (0)