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 2f077fc9 feat(read): preserve physical row IDs with data predicates
(#900)
2f077fc9 is described below
commit 2f077fc99828c6351a59b85ec98c9f9f4382eddc
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 21 18:11:49 2026 +0800
feat(read): preserve physical row IDs with data predicates (#900)
---
crates/paimon/src/table/data_file_reader.rs | 219 ++++++++++++++++------------
1 file changed, 125 insertions(+), 94 deletions(-)
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index cf429efc..95452cfc 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -236,32 +236,6 @@ impl DataFileReader {
.any(|p| !matches!(p, Predicate::AlwaysTrue))
}
- /// Reject projecting `_ROW_ID` alongside an exact predicate.
- /// `_ROW_ID` is assigned positionally from emitted batch row counts, so
- /// residual filtering or row-group/page pruning would desync it.
(`_ROW_ID`
- /// predicates travel via `row_ranges`, so they do not trip this.)
- fn reject_row_id_with_predicates(
- read_type: &[DataField],
- predicates: &[Predicate],
- ) -> crate::Result<()> {
- let projects_row_id = read_type
- .iter()
- .any(|field| field.name() == ROW_ID_FIELD_NAME);
- // Only predicates that can actually drop rows desync positional
`_ROW_ID`.
- // A constant `AlwaysTrue` keeps every row in order and is harmless,
so it
- // must not trip the guard.
- let has_row_filtering_predicate = predicates
- .iter()
- .any(|p| !matches!(p, Predicate::AlwaysTrue));
- if projects_row_id && has_row_filtering_predicate {
- return Err(crate::Error::Unsupported {
- message: "reading _ROW_ID together with a data predicate is
not supported yet"
- .to_string(),
- });
- }
- Ok(())
- }
-
/// Take a stream of DataSplits and read every data file in each split.
/// Returns a stream of Arrow RecordBatches from all files.
///
@@ -458,33 +432,47 @@ impl DataFileReader {
data_fields: Option<Vec<DataField>>,
row_selection: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
- // Guard at the true risk site: `_ROW_ID` is materialized positionally
from
- // each batch's row count (see `row_id_column_for_batch`), assuming the
- // reader emits rows in original file order and count. Format readers
may
- // skip row groups/pages or apply an exact row filter *before*
`_ROW_ID`
- // is assigned here, which would desync the ids. So projecting
`_ROW_ID`
- // together with a data predicate is unsupported — fail loudly
- // rather than return wrong ids. Placed here (not only in `read()`)
because
- // `read_single_file_stream` is also called directly by the KV and
- // data-evolution readers; both strip/omit `_ROW_ID` from the read_type
- // they pass, so this guard does not affect them.
- Self::reject_row_id_with_predicates(&self.read_type,
&self.predicates)?;
if row_selection.as_ref().is_some_and(Vec::is_empty) {
return Ok(futures::stream::empty().boxed());
}
- let read_type = self.read_type.clone();
+ let mut read_type = self.read_type.clone();
+ let output_field_count = read_type.len();
let table_fields = self.table_fields.clone();
let predicates = self.predicates.clone();
+ let projects_row_id = read_type
+ .iter()
+ .any(|field| field.name() == ROW_ID_FIELD_NAME);
+ let row_id_residual = projects_row_id
+ && predicates
+ .iter()
+ .any(|predicate| !matches!(predicate, Predicate::AlwaysTrue));
+ if row_id_residual {
+ // Decoder-side filtering can drop rows before positional `_ROW_ID`
+ // attachment. Read predicate-only columns without pushing the
filter
+ // down, attach the original ids, then evaluate the exact
predicate.
+ let mut refs = Vec::new();
+ for predicate in &predicates {
+ crate::arrow::residual::collect_predicate_leaf_refs(predicate,
&mut refs);
+ }
+ for (name, index) in refs {
+ if name != ROW_ID_FIELD_NAME {
+ if let Some(field) = table_fields.get(index) {
+ crate::arrow::residual::push_unique_scan_field(&mut
read_type, field);
+ }
+ }
+ }
+ }
+ let residual_predicates = row_id_residual.then(||
crate::arrow::format::FilePredicates {
+ predicates: predicates.clone(),
+ row_filter_factory: None,
+ file_fields: table_fields.clone(),
+ });
// The first version of the engine hook is deliberately limited to a
// schema-identical raw read. Schema-evolution readers retain their
exact
// post-filter until expression adaptation is proven for that path.
- // Positional `_ROW_ID` materialization must also see the unfiltered
row
- // stream, just like the predicate guard above.
- let projects_row_id = self
- .read_type
- .iter()
- .any(|field| field.name() == ROW_ID_FIELD_NAME);
+ // Positional `_ROW_ID` materialization must see the unfiltered row
+ // stream, so the engine hook stays disabled for this projection.
let row_filter_factory = (data_fields.is_none() && !projects_row_id)
.then(|| self.row_filter_factory.clone())
.flatten();
@@ -530,7 +518,9 @@ impl DataFileReader {
};
// Remap predicates from table-level to file-level indices.
- let file_predicates = {
+ let file_predicates = if row_id_residual {
+ None
+ } else {
let remapped = crate::arrow::filtering::remap_predicates_to_file(
&predicates,
&table_fields,
@@ -689,6 +679,25 @@ impl DataFileReader {
source: Some(Box::new(e)),
}
})?;
+ let result = if let Some(residual) = &residual_predicates {
+ let filtered =
crate::arrow::residual::filter_record_batch_by_predicates(
+ result,
+ residual,
+ &read_type,
+ )?;
+ if read_type.len() > output_field_count {
+
filtered.project(&(0..output_field_count).collect::<Vec<_>>()).map_err(|e| {
+ Error::UnexpectedError {
+ message: format!("Failed to project filtered
RecordBatch: {e}"),
+ source: Some(Box::new(e)),
+ }
+ })?
+ } else {
+ filtered
+ }
+ } else {
+ result
+ };
yield result;
}
}
@@ -1580,14 +1589,10 @@ mod row_tests {
assert_eq!(ages, vec![30, 40, 50]);
}
- /// Guard: projecting `_ROW_ID` together with a data predicate must fail
- /// loudly rather than assign wrong row ids. `_ROW_ID` is materialized
- /// positionally from post-filter batch row counts, so the readers'
residual
- /// filter dropping rows would desync it. See the guard in `read()`.
- #[tokio::test]
- async fn read_rejects_row_id_projection_with_data_predicate() {
- // Write a real .row file so read() reaches read_single_file_stream
(where
- // the guard lives). Project _ROW_ID alongside a data predicate →
Unsupported.
+ /// The predicate must not renumber a projected `_ROW_ID`: the surviving
row
+ /// keeps its original physical position even when the predicate column is
+ /// absent from the requested projection.
+ async fn assert_row_id_with_data_predicate(format: &str) {
let fields = vec![
field(0, "id", DataType::Int(IntType::new())),
field(1, "age", DataType::Int(IntType::new())),
@@ -1603,9 +1608,9 @@ mod row_tests {
.unwrap();
let file_io = FileIOBuilder::new("memory").build().unwrap();
- let table_path = "memory:/row_id_guard";
+ let table_path = format!("memory:/row_id_predicate_{format}");
let bucket_path = format!("{table_path}/bucket-0");
- let file_name = "part-0.row";
+ let file_name = format!("part-0.{format}");
let output = file_io
.new_output(&format!("{bucket_path}/{file_name}"))
.unwrap();
@@ -1615,13 +1620,15 @@ mod row_tests {
writer.write(&batch).await.unwrap();
let file_size = writer.close().await.unwrap().file_size as i64;
+ let mut file_meta = data_file(&file_name, file_size, 3, 1);
+ file_meta.first_row_id = Some(100);
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(BinaryRow::new(0))
.with_bucket(0)
.with_bucket_path(bucket_path)
.with_total_buckets(1)
- .with_data_files(vec![data_file(file_name, file_size, 3, 1)])
+ .with_data_files(vec![file_meta.clone()])
.build()
.unwrap();
@@ -1630,58 +1637,82 @@ mod row_tests {
ROW_ID_FIELD_NAME.to_string(),
DataType::BigInt(crate::spec::BigIntType::new()),
);
- // read_type projects _ROW_ID alongside a real column; predicate on
age.
- let read_type = vec![fields[1].clone(), row_id];
+ // Project only _ROW_ID; age must be read internally for the predicate.
+ let read_type = vec![row_id];
let predicate: Predicate = PredicateBuilder::new(&fields)
- .greater_than("age", Datum::Int(25))
+ .greater_than("age", Datum::Int(15))
.unwrap();
let reader = DataFileReader::new(
file_io.clone(),
- SchemaManager::new(file_io, table_path.to_string()),
+ SchemaManager::new(file_io, table_path),
1,
fields,
read_type,
vec![predicate],
);
- // The guard is inside read_single_file_stream, reached while
consuming the
- // stream, so the error surfaces on collect.
- let result =
reader.read(&[split]).unwrap().try_collect::<Vec<_>>().await;
- let err = match result {
- Ok(_) => panic!("must reject _ROW_ID + predicate"),
- Err(err) => err,
+ let batches = reader
+ .clone()
+ .read(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let row_ids = |batches: &[RecordBatch]| {
+ batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .values()
+ .iter()
+ .copied()
+ })
+ .collect::<Vec<_>>()
};
- assert!(
- matches!(&err, crate::Error::Unsupported { message } if
message.contains("_ROW_ID")),
- "expected Unsupported mentioning _ROW_ID, got: {err:?}"
- );
- }
+ assert_eq!(row_ids(&batches), vec![101, 102]);
- #[test]
- fn reject_row_id_guard_allows_constant_always_true_predicate() {
- // A constant AlwaysTrue keeps every row in order, so it cannot desync
- // positional _ROW_ID and must NOT trip the guard.
- let row_id = DataField::new(
- crate::spec::ROW_ID_FIELD_ID,
- ROW_ID_FIELD_NAME.to_string(),
- DataType::BigInt(crate::spec::BigIntType::new()),
- );
- let read_type = vec![row_id];
- // AlwaysTrue alone -> allowed.
- assert!(
- DataFileReader::reject_row_id_with_predicates(&read_type,
&[Predicate::AlwaysTrue])
- .is_ok(),
- "AlwaysTrue must not trip the _ROW_ID guard"
- );
- // A real filtering predicate -> rejected.
- let filtering = PredicateBuilder::new(&[field(0, "age",
DataType::Int(IntType::new()))])
- .greater_than("age", Datum::Int(1))
+ let selected = reader
+ .clone()
+ .read_single_file_stream(
+ &split,
+ file_meta.clone(),
+ None,
+ None,
+ Some(vec![RowRange::new(100, 101)]),
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
.unwrap();
- assert!(
- DataFileReader::reject_row_id_with_predicates(&read_type,
&[filtering]).is_err(),
- "a row-filtering predicate must trip the _ROW_ID guard"
- );
+ assert_eq!(row_ids(&selected), vec![101]);
+
+ let mut deleted = RoaringBitmap::new();
+ deleted.insert(2);
+ let selected = reader
+ .read_single_file_stream(
+ &split,
+ file_meta,
+ None,
+ Some(Arc::new(DeletionVector::from_bitmap(deleted))),
+ None,
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(row_ids(&selected), vec![101]);
+ }
+
+ #[tokio::test]
+ async fn read_row_id_with_data_predicate_keeps_physical_position() {
+ for format in ["row", "parquet"] {
+ assert_row_id_with_data_predicate(format).await;
+ }
}
}