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 2457d30c [core] Support row-tracking sequence projections and
predicates (#1051)
2457d30c is described below
commit 2457d30c8f902e34a1f6d55134ce970e52c3cfb6
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 08:53:14 2026 +0800
[core] Support row-tracking sequence projections and predicates (#1051)
---
bindings/python/src/predicate.rs | 26 +-
bindings/python/src/read.rs | 3 +-
bindings/python/src/write.rs | 2 +-
bindings/python/tests/test_read.py | 50 +-
crates/paimon/src/arrow/filtering.rs | 6 +-
crates/paimon/src/arrow/format/orc.rs | 6 +-
crates/paimon/src/arrow/format/parquet.rs | 6 +-
crates/paimon/src/arrow/residual.rs | 27 +-
crates/paimon/src/predicate_stats.rs | 10 +-
crates/paimon/src/spec/core_options.rs | 7 -
crates/paimon/src/spec/predicate.rs | 10 +-
crates/paimon/src/spec/schema.rs | 47 +-
crates/paimon/src/table/data_evolution_reader.rs | 9 +-
crates/paimon/src/table/data_file_reader.rs | 43 +-
crates/paimon/src/table/kv_file_reader.rs | 19 +-
crates/paimon/src/table/read_builder.rs | 84 ++-
crates/paimon/src/table/row_sidecar.rs | 10 +-
crates/paimon/src/table/sort_merge.rs | 59 +-
crates/paimon/src/table/stats_filter.rs | 9 +-
crates/paimon/src/table/table_scan.rs | 29 +
.../tests/data_evolution_row_sidecar_test.rs | 32 +-
.../tests/data_evolution_system_columns_test.rs | 599 +++++++++++++++++++++
22 files changed, 950 insertions(+), 143 deletions(-)
diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index f164feda..f3f3ff7a 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -15,7 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-use paimon::spec::{DataField, DataType, Datum, DecimalType, Predicate,
PredicateBuilder};
+use paimon::spec::{
+ DataField, DataType, Datum, DecimalType, Predicate, PredicateBuilder,
TableSchema,
+};
use pyo3::exceptions::{PyNotImplementedError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyTzInfoAccess;
@@ -419,29 +421,13 @@ pub(crate) fn dict_to_predicate(
}
}
-/// Convert against table fields plus the synthesized `_ROW_ID` field.
+/// Use the core read schema, including only metadata the table provides.
pub(crate) fn dict_to_table_predicate(
node: &Bound<'_, PyDict>,
- fields: &[DataField],
+ schema: &TableSchema,
case_sensitive: bool,
) -> PyResult<Predicate> {
- use paimon::spec::{BigIntType, ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME};
- let mut fields = fields.to_vec();
- let has_row_id = fields.iter().any(|field| {
- if case_sensitive {
- field.name() == ROW_ID_FIELD_NAME
- } else {
- field.name().eq_ignore_ascii_case(ROW_ID_FIELD_NAME)
- }
- });
- if !has_row_id {
- fields.push(DataField::new(
- ROW_ID_FIELD_ID,
- ROW_ID_FIELD_NAME.into(),
- DataType::BigInt(BigIntType::with_nullable(true)),
- ));
- }
- dict_to_predicate(node, &fields, case_sensitive)
+ dict_to_predicate(node, &schema.fields_with_read_metadata(),
case_sensitive)
}
/// Resolve a leaf's field name to its schema [`DataType`] under the given case
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 8cbaf515..33c951d1 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -281,8 +281,7 @@ impl PyReadBuilder {
mut slf: PyRefMut<'py, Self>,
predicate: &Bound<'_, PyDict>,
) -> PyResult<PyRefMut<'py, Self>> {
- let filter =
- dict_to_table_predicate(predicate, slf.table.schema().fields(),
slf.case_sensitive)?;
+ let filter = dict_to_table_predicate(predicate, slf.table.schema(),
slf.case_sensitive)?;
slf.filter = Some(filter);
Ok(slf)
}
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 6c619e65..23d0cd62 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -464,7 +464,7 @@ impl UpdateContext {
read_columns: Option<Vec<String>>,
) -> PyResult<Vec<PyCommitMessage>> {
let predicate = predicate
- .map(|predicate| dict_to_table_predicate(predicate,
self.table.schema().fields(), true))
+ .map(|predicate| dict_to_table_predicate(predicate,
self.table.schema(), true))
.transpose()?;
let callback_error = Arc::new(Mutex::new(None));
let assignments = crate::update_assignment::from_python(
diff --git a/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 27642786..7a6903c6 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -405,22 +405,60 @@ def
test_row_id_filter_with_projection_and_data_predicate(data_evolution, case_s
assert rows.to_pylist() == [{"name": "b", "_ROW_ID": 1}]
[email protected]("predicate_field", ["_row_id", "_ROW_ID"])
-def
test_case_insensitive_filter_uses_real_lowercase_row_id_column(predicate_field):
[email protected]("user_field", ["_row_id", "_sequence_number"])
[email protected]("uppercase", [False, True])
+def test_case_insensitive_filter_uses_untracked_user_column(user_field,
uppercase):
with tempfile.TemporaryDirectory() as warehouse:
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.realrowid")
- ctx.sql("CREATE TABLE paimon.realrowid.t (_row_id BIGINT, id INT)")
+ ctx.sql(f"CREATE TABLE paimon.realrowid.t ({user_field} BIGINT, id
INT)")
ctx.sql("INSERT INTO paimon.realrowid.t VALUES (11, 1), (22, 2)")
table = PaimonCatalog({"warehouse":
warehouse}).get_table("realrowid.t")
builder = table.new_read_builder().with_case_sensitive(False)
- builder.with_projection(["_row_id", "id"])
+ # Projection and predicate resolution must use the same available
+ # fields. Untracked append tables have no virtual tracking columns.
+ field = user_field.upper() if uppercase else user_field
+ builder.with_projection([field, "id"])
builder.with_filter({
- "method": "equal", "field": predicate_field, "literals": [22],
+ "method": "equal", "field": field, "literals": [22],
})
batches = builder.new_read().read(builder.new_scan().plan().splits())
- assert pa.Table.from_batches(batches).to_pylist() == [{"_row_id": 22,
"id": 2}]
+ assert pa.Table.from_batches(batches).to_pylist() == [{user_field: 22,
"id": 2}]
+
+
[email protected]("user_field", ["_row_id", "_sequence_number"])
[email protected]("uppercase", [False, True])
+def test_case_insensitive_tracking_metadata_collision_is_ambiguous(user_field,
uppercase):
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.trackingcollision")
+ ctx.sql(f"""CREATE TABLE paimon.trackingcollision.t ({user_field}
BIGINT, id INT)
+ WITH ('row-tracking.enabled' = 'true')""")
+ table = PaimonCatalog({"warehouse":
warehouse}).get_table("trackingcollision.t")
+ builder = table.new_read_builder().with_case_sensitive(False)
+ field = user_field.upper() if uppercase else user_field
+ with pytest.raises(ValueError, match="Ambiguous"):
+ builder.with_filter({"method": "equal", "field": field,
"literals": [22]})
+ with pytest.raises(ValueError, match="Ambiguous"):
+ builder.with_projection([field]).new_read().read([])
+
+
+def test_primary_key_sequence_metadata_does_not_require_row_tracking():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.pksequence")
+ ctx.sql("""CREATE TABLE paimon.pksequence.t (id INT, v INT, PRIMARY
KEY (id))
+ WITH ('bucket' = '1', 'row-tracking.enabled' = 'false')""")
+ ctx.sql("INSERT INTO paimon.pksequence.t VALUES (1, 11), (2, 22)")
+ table = PaimonCatalog({"warehouse":
warehouse}).get_table("pksequence.t")
+ builder = table.new_read_builder().with_case_sensitive(False)
+ builder.with_projection(["id", "_sequence_number"])
+ builder.with_filter({"method": "equal", "field": "_sequence_number",
"literals": [1]})
+ result =
pa.Table.from_batches(builder.new_read().read(builder.new_scan().plan().splits()))
+ assert result.to_pylist() == [{"id": 2, "_SEQUENCE_NUMBER": 1}]
def test_row_tracking_append_row_ranges_keep_global_row_ids():
diff --git a/crates/paimon/src/arrow/filtering.rs
b/crates/paimon/src/arrow/filtering.rs
index b502e7f7..3ae911a2 100644
--- a/crates/paimon/src/arrow/filtering.rs
+++ b/crates/paimon/src/arrow/filtering.rs
@@ -17,7 +17,7 @@
use crate::arrow::schema_evolution::create_index_mapping;
pub(crate) use crate::predicate_stats::{predicates_may_match_with_schema,
StatsAccessor};
-use crate::spec::{is_row_id_column, DataField, Predicate, PredicateOperator};
+use crate::spec::{is_row_tracking_column, DataField, Predicate,
PredicateOperator};
/// Remap predicates from table-level indices to file-level indices.
/// Predicates referencing fields not present in the file are resolved based on
@@ -44,10 +44,10 @@ fn remap_predicate(predicate: &Predicate, mapping:
&[Option<usize>]) -> Predicat
op,
literals,
} => {
- // `_ROW_ID` is not a file column and has no per-file position, so
+ // Row-tracking metadata has no logical-table position, so
// mapping its placeholder index would collapse the leaf to a
// constant. Keep it; the residual resolves it by name.
- if is_row_id_column(column) {
+ if is_row_tracking_column(column) {
return predicate.clone();
}
match mapping.get(*index).copied().flatten() {
diff --git a/crates/paimon/src/arrow/format/orc.rs
b/crates/paimon/src/arrow/format/orc.rs
index 85c77780..79bd02f9 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -17,7 +17,9 @@
use super::{FilePredicates, FormatFileReader, FormatFileWriter,
FormatWriteResult};
use crate::io::{FileRead, FileWrite, OutputFile};
-use crate::spec::{is_row_id_column, DataField, DataType, Datum, Predicate,
PredicateOperator};
+use crate::spec::{
+ is_row_tracking_column, DataField, DataType, Datum, Predicate,
PredicateOperator,
+};
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
use arrow_array::RecordBatch;
@@ -547,7 +549,7 @@ fn build_orc_leaf_predicate(
return None;
};
// Not in the file, and its index would push the wrong column down.
- if is_row_id_column(column) {
+ if is_row_tracking_column(column) {
return None;
}
let file_field = file_fields.get(*index)?;
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 81b9f15b..44efbc8c 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -29,8 +29,8 @@ use crate::arrow::{ReadBudget, RowFilter, RowFilterContext};
use crate::io::{FileRead, OutputFile};
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- is_row_id_column, BinaryRowBuilder, CoreOptions, DataField, DataType,
Datum, MetadataStatsMode,
- Predicate, PredicateOperator,
+ is_row_tracking_column, BinaryRowBuilder, CoreOptions, DataField,
DataType, Datum,
+ MetadataStatsMode, Predicate, PredicateOperator,
};
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
@@ -1469,7 +1469,7 @@ fn parquet_leaf_row_filter_accepted(
}
// Not in the file, so the decoder cannot evaluate it. Rejecting the leaf
// rejects any enclosing predicate too, leaving it to the post-scan
residual.
- if is_row_id_column(column) {
+ if is_row_tracking_column(column) {
return Ok(false);
}
let Some(file_field) = file_fields.get(index) else {
diff --git a/crates/paimon/src/arrow/residual.rs
b/crates/paimon/src/arrow/residual.rs
index f1e5fe2a..eaf463a8 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -45,7 +45,9 @@
//! must not reference any vortex-specific types.
use crate::arrow::format::FilePredicates;
-use crate::spec::{is_row_id_column, DataField, DataType, Datum, Predicate,
PredicateOperator};
+use crate::spec::{
+ is_row_tracking_column, DataField, DataType, Datum, Predicate,
PredicateOperator,
+};
use crate::Error;
use arrow_array::{
Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Datum as
ArrowDatum, Decimal128Array,
@@ -168,10 +170,10 @@ fn evaluate_predicate_mask(
} => {
// Resolve the batch column by NAME, but pick that name carefully:
a
// real column may be renamed in the file, so its batch name comes
- // from `file_fields[index]`; `_ROW_ID` is absent from
`file_fields`
+ // from `file_fields[index]`; row-tracking metadata is absent from
the logical `file_fields`
// and only its own name is meaningful. Java's `PredicateRemapper`
// rebinds by name too.
- let file_field = if is_row_id_column(column) {
+ let file_field = if is_row_tracking_column(column) {
None
} else {
match file_fields.get(*index) {
@@ -196,9 +198,14 @@ fn evaluate_predicate_mask(
// Backstop for residuals applied to a predicate-free
reader
// (PK merge output, vector search); readers that own their
// predicates reject earlier.
- return
Err(crate::table::row_id_predicate::unsupported_row_id_filter(
- "this read",
- ));
+ if crate::spec::is_row_id_column(field_name) {
+ return
Err(crate::table::row_id_predicate::unsupported_row_id_filter(
+ "this read",
+ ));
+ }
+ return Err(Error::Unsupported {
+ message: format!("filtering on '{field_name}' requires
this read to supply the row-tracking column"),
+ });
}
// A real column missing here is a reader bug: it did not widen
// its scan to the predicate columns.
@@ -265,8 +272,12 @@ pub(crate) fn widen_scan_fields(
collect_predicate_leaf_refs(predicate, &mut refs);
}
for (name, index) in refs {
+ if name == crate::spec::SEQUENCE_NUMBER_FIELD_NAME {
+ push_unique_scan_field(&mut fields,
&crate::spec::sequence_number_data_field());
+ continue;
+ }
// Not read from the file, so there is nothing to widen with.
- if is_row_id_column(name) {
+ if is_row_tracking_column(name) {
continue;
}
if let Some(field) = fp.file_fields.get(index) {
@@ -281,7 +292,7 @@ pub(crate) fn widen_scan_fields(
/// Collect every leaf as `(column name, leaf index)`.
///
/// Callers that resolve a leaf positionally must check the name first — see
-/// [`crate::spec::is_row_id_column`].
+/// [`crate::spec::is_row_tracking_column`].
pub(crate) fn collect_predicate_leaf_refs<'a>(
predicate: &'a Predicate,
refs: &mut Vec<(&'a str, usize)>,
diff --git a/crates/paimon/src/predicate_stats.rs
b/crates/paimon/src/predicate_stats.rs
index c30c5465..a111a658 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -15,7 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-use crate::spec::{is_row_id_column, DataField, DataType, Datum, Predicate,
PredicateOperator};
+use crate::spec::{
+ is_row_tracking_column, DataField, DataType, Datum, Predicate,
PredicateOperator,
+};
use std::cmp::Ordering;
pub(crate) trait StatsAccessor {
@@ -494,8 +496,8 @@ fn predicate_may_match_with_schema<T: StatsAccessor>(
Predicate::Not(inner) => {
!predicate_must_match_with_schema(inner, stats, field_mapping,
file_fields)
}
- // `_ROW_ID` has no column stats, so never prune on it.
- Predicate::Leaf { column, .. } if is_row_id_column(column) => true,
+ // Row-tracking metadata has no logical column stats, so never prune
on it.
+ Predicate::Leaf { column, .. } if is_row_tracking_column(column) =>
true,
Predicate::Leaf {
index,
data_type,
@@ -540,7 +542,7 @@ fn predicate_must_match_with_schema<T: StatsAccessor>(
!predicate_may_match_with_schema(inner, stats, field_mapping,
file_fields)
}
// Stats cannot decide `_ROW_ID`, so it never provably matches.
- Predicate::Leaf { column, .. } if is_row_id_column(column) => false,
+ Predicate::Leaf { column, .. } if is_row_tracking_column(column) =>
false,
Predicate::Leaf {
index,
data_type,
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index f67a5e37..beee183a 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -773,13 +773,6 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
- /// Whether a missing optional read target may fall back to the primary
file.
- pub fn scan_ignore_lost_file(&self) -> bool {
- self.options
- .get("scan.ignore-lost-files")
- .is_some_and(|value| value.eq_ignore_ascii_case("true"))
- }
-
/// Whether normal Data Evolution files also write an aligned ROW sidecar.
pub fn data_evolution_row_sidecar_enabled(&self) -> crate::Result<bool> {
match self.options.get("data-evolution.row-sidecar.enabled") {
diff --git a/crates/paimon/src/spec/predicate.rs
b/crates/paimon/src/spec/predicate.rs
index 2abe34eb..73a078c2 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -26,7 +26,7 @@
use crate::error::*;
use crate::spec::binary_row::BinaryRow;
use crate::spec::types::DataType;
-use crate::spec::{is_row_id_column, DataField};
+use crate::spec::{is_row_tracking_column, DataField};
use std::cmp::Ordering;
use std::fmt;
@@ -468,7 +468,7 @@ impl Predicate {
op,
literals,
} => {
- if is_row_id_column(column) {
+ if is_row_tracking_column(column) {
return None;
}
let new_index = (*mapping.get(*index)?)?;
@@ -510,7 +510,7 @@ impl Predicate {
pub(crate) fn references_only_mapped_fields(&self, mapping:
&[Option<usize>]) -> bool {
match self {
Predicate::Leaf { column, index, .. } => {
- !is_row_id_column(column) &&
mapping.get(*index).is_some_and(Option::is_some)
+ !is_row_tracking_column(column) &&
mapping.get(*index).is_some_and(Option::is_some)
}
Predicate::And(children) | Predicate::Or(children) => children
.iter()
@@ -522,7 +522,7 @@ impl Predicate {
/// Project leaf field indices from table schema space into a smaller
field space.
///
- /// A `_ROW_ID` leaf never maps — see [`is_row_id_column`].
+ /// A row-tracking metadata leaf never maps — see
[`is_row_tracking_column`].
///
/// Unlike [`Self::remap_field_index`], mixed `AND` subtrees keep the
children
/// that can be projected and drop the rest. `OR` and `NOT` still require
all
@@ -542,7 +542,7 @@ impl Predicate {
op,
literals,
} => {
- if is_row_id_column(column) {
+ if is_row_tracking_column(column) {
return None;
}
let new_index = (*mapping.get(*index)?)?;
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index b6f73e09..2377b4be 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -99,6 +99,26 @@ impl TableSchema {
&self.fields
}
+ /// Logical fields followed by the metadata available to table reads.
+ /// Row tracking provides ROW_ID and SEQUENCE_NUMBER; primary-key files
+ /// provide SEQUENCE_NUMBER independently of row tracking. Keeping this
+ /// schema shared makes projection and predicate name resolution agree.
+ pub fn fields_with_read_metadata(&self) -> Vec<DataField> {
+ let mut fields = self.fields.clone();
+ let row_tracking =
CoreOptions::new(&self.options).row_tracking_enabled();
+ if row_tracking && !fields.iter().any(|field| field.name() ==
ROW_ID_FIELD_NAME) {
+ fields.push(row_id_data_field());
+ }
+ if (row_tracking || !self.primary_keys.is_empty())
+ && !fields
+ .iter()
+ .any(|field| field.name() == SEQUENCE_NUMBER_FIELD_NAME)
+ {
+ fields.push(sequence_number_data_field());
+ }
+ fields
+ }
+
pub fn highest_field_id(&self) -> i32 {
self.highest_field_id
}
@@ -1147,19 +1167,28 @@ pub(crate) fn row_id_data_field() -> DataField {
)
}
-/// `_ROW_ID` is synthesized by the reader and is not a table column, so
-/// `PredicateBuilder` cannot resolve it and callers hand-build the leaf with a
-/// placeholder index. Every index-based resolution must recognize it by name
-/// instead, or it binds the predicate to whatever field sits at that index.
-///
-/// The other reserved names are excluded on purpose. `_SEQUENCE_NUMBER` and
-/// `_VALUE_KIND` are physical columns of a KV file and do have a position;
none
-/// of them is ever referenced by a predicate. Widening this to every reserved
-/// name would instead break a schema that predates their rejection.
+/// `_ROW_ID` conditions alone can be converted to positional row ranges.
pub(crate) fn is_row_id_column(name: &str) -> bool {
name == ROW_ID_FIELD_NAME
}
+/// Java's row-tracking version column. A missing or NULL physical value is
+/// supplied from the data file's max sequence number.
+pub(crate) fn sequence_number_data_field() -> DataField {
+ DataField::new(
+ SEQUENCE_NUMBER_FIELD_ID,
+ SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+ DataType::BigInt(crate::spec::BigIntType::with_nullable(false)),
+ )
+}
+
+/// Row-tracking metadata has no index in the logical table schema. Resolve
+/// predicates by reserved name instead of interpreting a placeholder index as
+/// a user column. `_VALUE_KIND` remains a physical KV column.
+pub(crate) fn is_row_tracking_column(name: &str) -> bool {
+ is_row_id_column(name) || name == SEQUENCE_NUMBER_FIELD_NAME
+}
+
/// Must match Java Paimon's `SpecialFields.ROW_KIND` (Integer.MAX_VALUE - 4).
pub const ROW_KIND_FIELD_ID: i32 = i32::MAX - 4;
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index 123a138f..3e6ecb83 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -1343,7 +1343,7 @@ fn predicate_references_any_field(
Predicate::Leaf { column, index, .. } => {
// Never a BLOB column; resolving its placeholder index would force
// every BLOB to be resolved before filtering.
- if crate::spec::is_row_id_column(column) {
+ if crate::spec::is_row_tracking_column(column) {
return false;
}
field_names.contains(column)
@@ -2462,6 +2462,13 @@ fn build_source_plan_with_row_id_pushdown(
data_fields: info.data_fields.clone(),
read_fields: Vec::new(),
});
+ // Java rowTypeWithRowTracking adds this metadata provider even
+ // when write_cols omits the physical sequence column. The newest
+ // normal file supplies the group version, independently of the
+ // requested data columns.
+ normal_providers
+ .entry(crate::spec::SEQUENCE_NUMBER_FIELD_ID)
+ .or_insert(source_idx);
for &field_id in &info.field_ids {
// first normal file that carries the id wins (preserve
existing semantics)
normal_providers.entry(field_id).or_insert(source_idx);
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 808f795b..d12e49df 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -537,27 +537,46 @@ impl DataFileReader {
let projects_row_id = read_type
.iter()
.any(|field| field.name() == ROW_ID_FIELD_NAME);
- let row_id_residual = projects_row_id
+ let row_tracking_enabled =
+
crate::spec::CoreOptions::new(&self.table_options).row_tracking_enabled();
+ let mut refs = Vec::new();
+ for predicate in &predicates {
+ crate::arrow::residual::collect_predicate_leaf_refs(predicate,
&mut refs);
+ }
+ let metadata_residual = (projects_row_id
&& predicates
.iter()
- .any(|predicate| !matches!(predicate, Predicate::AlwaysTrue));
- if row_id_residual {
+ .any(|predicate| !matches!(predicate, Predicate::AlwaysTrue)))
+ || (row_tracking_enabled
+ && refs
+ .iter()
+ .any(|(name, _)| *name ==
crate::spec::SEQUENCE_NUMBER_FIELD_NAME));
+ if metadata_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 name == crate::spec::SEQUENCE_NUMBER_FIELD_NAME {
+ crate::arrow::residual::push_unique_scan_field(
+ &mut read_type,
+ &crate::spec::sequence_number_data_field(),
+ );
+ } else if name == ROW_ID_FIELD_NAME {
+ crate::arrow::residual::push_unique_scan_field(
+ &mut read_type,
+ &crate::spec::row_id_data_field(),
+ );
+ } else {
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 {
+ let projects_row_id = read_type
+ .iter()
+ .any(|field| field.name() == ROW_ID_FIELD_NAME);
+ let residual_predicates = metadata_residual.then(||
crate::arrow::format::FilePredicates {
predicates: predicates.clone(),
row_filter_factory: None,
file_fields: table_fields.clone(),
@@ -567,7 +586,7 @@ impl DataFileReader {
// post-filter until expression adaptation is proven for that path.
// 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)
+ let row_filter_factory = (data_fields.is_none() && !projects_row_id &&
!metadata_residual)
.then(|| self.row_filter_factory.clone())
.flatten();
let file_io = self.file_io.clone();
@@ -581,8 +600,6 @@ impl DataFileReader {
let read_timing = self.read_timing.clone();
let target_schema = build_target_arrow_schema(&read_type)?;
- let row_tracking_enabled =
-
crate::spec::CoreOptions::new(&table_options).row_tracking_enabled();
let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
let data_schema_fields = data_schema_fields_for_file(
&file_fields,
@@ -616,7 +633,7 @@ impl DataFileReader {
mosaic_prefetch,
)?;
// Remap predicates from table-level to file-level indices.
- let file_predicates = if row_id_residual {
+ let file_predicates = if metadata_residual {
None
} else {
let remapped = crate::arrow::filtering::remap_predicates_to_file(
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index 0e67eae3..3b603133 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -506,7 +506,10 @@ impl KeyValueFileReader {
let user_fields = crate::arrow::residual::widen_scan_fields(
&user_fields,
residual_file_predicates.as_ref(),
- );
+ )
+ .into_iter()
+ .filter(|field| !matches!(field.id(), SEQUENCE_NUMBER_FIELD_ID |
VALUE_KIND_FIELD_ID))
+ .collect();
let user_fields = widen_partial_update_sequence_group_fields(
self.config.merge_engine,
&self.config.table_options,
@@ -529,6 +532,13 @@ impl KeyValueFileReader {
// Indices within internal_schema (offset 2 for _SEQ and _VK).
let seq_index = 0;
let value_kind_index = 1;
+ let mut predicate_refs = Vec::new();
+ for predicate in &self.config.predicates {
+ crate::arrow::residual::collect_predicate_leaf_refs(predicate,
&mut predicate_refs);
+ }
+ let needs_sequence = predicate_refs
+ .iter()
+ .any(|(name, _)| *name == SEQUENCE_NUMBER_FIELD_NAME);
let key_indices: Vec<usize> = self
.config
.primary_keys
@@ -547,6 +557,7 @@ impl KeyValueFileReader {
.filter(|(index, field)| {
!key_names.contains(field.name())
&& (*index >= 2
+ || (needs_sequence && field.id() ==
SEQUENCE_NUMBER_FIELD_ID)
|| self
.config
.read_type
@@ -1221,9 +1232,9 @@ mod tests {
vec![Datum::Long(102)],
);
let mut read_builder = table.new_read_builder();
- read_builder
- .with_projection(&["id", "value", crate::spec::ROW_ID_FIELD_NAME])
- .unwrap();
+ // Untracked PK tables do not advertise ROW_ID. A hand-built predicate
+ // must still be rejected rather than binding its placeholder index.
+ read_builder.with_projection(&["id", "value"]).unwrap();
read_builder.with_filter(row_id);
let plan = read_builder.new_scan().plan().await.unwrap();
let err = read_builder
diff --git a/crates/paimon/src/table/read_builder.rs
b/crates/paimon/src/table/read_builder.rs
index b3fd6730..dfffa31d 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -389,7 +389,7 @@ impl<'a> PaimonReadBuilder<'a> {
let projection_names: Vec<String> = columns.iter().map(|c|
(*c).to_string()).collect();
validate_projection_possible(
self.table.identifier().full_name(),
- self.table.schema.fields(),
+ &self.table.schema.fields_with_read_metadata(),
&projection_names,
)?;
self.projection_names = Some(projection_names);
@@ -606,7 +606,7 @@ impl<'a> PaimonReadBuilder<'a> {
) -> Result<Vec<DataField>> {
resolve_projected_fields(
self.table.identifier().full_name(),
- self.table.schema.fields(),
+ &self.table.schema.fields_with_read_metadata(),
projection_names,
self.case_sensitive,
)
@@ -632,9 +632,6 @@ pub(super) fn validate_projection_possible(
.map(|f| f.name().to_ascii_lowercase())
.collect();
for name in projection_names {
- if name == crate::spec::ROW_ID_FIELD_NAME {
- continue;
- }
if !folded_names.contains(&name.to_ascii_lowercase()) {
return Err(Error::ColumnNotExist {
full_name: full_name.clone(),
@@ -693,11 +690,6 @@ pub(super) fn resolve_projected_fields(
});
}
- if name == crate::spec::ROW_ID_FIELD_NAME {
- resolved.push(crate::spec::row_id_data_field());
- continue;
- }
-
let field = if case_sensitive {
sensitive_index.get(name.as_str()).copied()
} else {
@@ -726,7 +718,10 @@ pub(super) fn resolve_projected_fields(
pub(super) fn projected_read_field_ids_from_fields(fields: &[DataField]) ->
HashSet<i32> {
fields
.iter()
- .filter(|field| !is_system_projection_field(field.id()))
+ .filter(|field| {
+ field.id() == crate::spec::SEQUENCE_NUMBER_FIELD_ID
+ || !is_system_projection_field(field.id())
+ })
.map(|field| field.id())
.collect::<HashSet<_>>()
}
@@ -748,9 +743,14 @@ fn projected_read_field_ids_with_predicates(
crate::arrow::residual::collect_predicate_leaf_refs(predicate, &mut
refs);
}
for (name, index) in refs {
+ if name == crate::spec::SEQUENCE_NUMBER_FIELD_NAME {
+ // The version provider need not write any projected user column.
+ field_ids.insert(crate::spec::SEQUENCE_NUMBER_FIELD_ID);
+ continue;
+ }
// Not in `table_fields`, and excluded from this set anyway — files
never
// list it in `write_cols`.
- if crate::spec::is_row_id_column(name) {
+ if crate::spec::is_row_tracking_column(name) {
continue;
}
let Some(field) = table_fields.get(index) else {
@@ -1652,6 +1652,66 @@ mod tests {
assert!(matches!(err, crate::Error::ConfigInvalid { .. }));
}
+ #[test]
+ fn test_metadata_projection_case_insensitive_collision_is_ambiguous() {
+ for (user, metadata) in [
+ ("_sequence_number", "_SEQUENCE_NUMBER"),
+ ("_row_id", "_ROW_ID"),
+ ] {
+ let untracked = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column(user, DataType::Int(IntType::new()))
+ .build()
+ .unwrap(),
+ );
+ let tracked = untracked.copy_with_options(HashMap::from([(
+ "row-tracking.enabled".into(),
+ "true".into(),
+ )]));
+ let fields = tracked.fields_with_read_metadata();
+ let metadata_fields = fields
+ .iter()
+ .filter(|field| field.id() != 0)
+ .cloned()
+ .collect::<Vec<_>>();
+ for name in [user, metadata] {
+ let err =
+ super::resolve_projected_fields("db.t".into(), &fields,
&[name.into()], false)
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("Ambiguous"))
+ );
+ let resolved = super::resolve_projected_fields(
+ "db.t".into(),
+ &metadata_fields,
+ &[name.into()],
+ false,
+ )
+ .unwrap();
+ assert_eq!(resolved[0].name(), metadata);
+ let resolved = super::resolve_projected_fields(
+ "db.t".into(),
+ &untracked.fields_with_read_metadata(),
+ &[name.into()],
+ false,
+ )
+ .unwrap();
+ assert_eq!(resolved[0].name(), user);
+ assert_eq!(resolved[0].id(), 0);
+ }
+ let fields = super::resolve_projected_fields(
+ "db.t".into(),
+ &fields,
+ &[user.into(), metadata.into()],
+ true,
+ )
+ .unwrap();
+ assert_eq!(fields[0].id(), 0);
+ assert_ne!(fields[1].id(), 0);
+ }
+ }
+
#[test]
fn test_resolve_projection_case_insensitive_dedups_by_folded_name() {
// With case-insensitive matching, `["Name","name"]` both resolve to
the
diff --git a/crates/paimon/src/table/row_sidecar.rs
b/crates/paimon/src/table/row_sidecar.rs
index 5cbdc953..9e7dd87d 100644
--- a/crates/paimon/src/table/row_sidecar.rs
+++ b/crates/paimon/src/table/row_sidecar.rs
@@ -241,14 +241,8 @@ pub(super) async fn read_target(
) -> Result<(String, u64)> {
if let Some(name) = selected_sidecar(file, local_ranges, options)? {
let path = file.aligned_file_path(bucket_path, name);
- match io.get_status(&path).await {
- Ok(status) => return Ok((path, status.size)),
- Err(error) => {
- if !options.scan_ignore_lost_file() {
- return Err(error);
- }
- }
- }
+ let status = io.get_status(&path).await?;
+ return Ok((path, status.size));
}
Ok((file.data_file_path(bucket_path), file.file_size as u64))
}
diff --git a/crates/paimon/src/table/sort_merge.rs
b/crates/paimon/src/table/sort_merge.rs
index 2641d68c..665ff7d9 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -251,6 +251,7 @@ pub(crate) struct PartialUpdateMergeFunction {
ignore_delete: bool,
remove_record_on_delete: bool,
sequence_group_partial_delete: HashSet<usize>,
+ sequence_group_enabled: bool,
sequence_groups: Vec<RuntimeSequenceGroup>,
grouped_fields: HashSet<usize>,
aggregators: Option<Mutex<FieldAggregatorSlots>>,
@@ -258,6 +259,32 @@ pub(crate) struct PartialUpdateMergeFunction {
type FieldAggregatorSlots = Vec<Option<Box<dyn FieldAggregator>>>;
+/// KV metadata belongs to the merged record, outside user-field aggregation.
+/// Java merge functions carry the latest effective sequence separately and
+/// normalize surviving records to INSERT, even when the last input retracts.
+fn merged_metadata_column(
+ field: &arrow_schema::Field,
+ row: &MergeRow,
+ deleted: bool,
+) -> Option<ArrayRef> {
+ match field.name().as_str() {
+ crate::spec::SEQUENCE_NUMBER_FIELD_NAME => {
+ Some(std::sync::Arc::new(Int64Array::from(vec![
+ row.sequence_number,
+ ])))
+ }
+ crate::spec::VALUE_KIND_FIELD_NAME => {
+ let kind = if deleted {
+ RowKind::Delete
+ } else {
+ RowKind::Insert
+ };
+ Some(std::sync::Arc::new(Int8Array::from(vec![kind as i8])))
+ }
+ _ => None,
+ }
+}
+
#[derive(Debug, Clone)]
struct RuntimeSequenceGroup {
sequence_indices: Vec<usize>,
@@ -333,6 +360,7 @@ impl PartialUpdateMergeFunction {
remove_record_on_delete: PartialUpdateConfig::new(table_options)
.remove_record_on_delete(),
sequence_group_partial_delete: HashSet::new(),
+ sequence_group_enabled: false,
sequence_groups: Vec::new(),
grouped_fields: HashSet::new(),
aggregators: None,
@@ -349,6 +377,7 @@ impl PartialUpdateMergeFunction {
let config = PartialUpdateConfig::new(table_options);
config.validate_read_mode(true, table_name)?;
let groups = config.validated_sequence_groups(table_fields,
primary_keys)?;
+ let sequence_group_enabled = !groups.is_empty();
let declared_group_sequences: HashSet<&str> = groups
.iter()
.flat_map(|group| group.sequence_fields.iter().map(String::as_str))
@@ -441,6 +470,7 @@ impl PartialUpdateMergeFunction {
ignore_delete: CoreOptions::new(table_options).ignore_delete(),
remove_record_on_delete: config.remove_record_on_delete(),
sequence_group_partial_delete,
+ sequence_group_enabled,
sequence_groups,
grouped_fields,
aggregators: aggregators
@@ -506,6 +536,7 @@ impl PartialUpdateMergeFunction {
let mut saw_add = false;
let mut current_delete_row = false;
let mut last_retract_source = None;
+ let mut latest_row = None;
for row_idx in ordered_row_indices {
let row = &rows[row_idx];
@@ -514,6 +545,7 @@ impl PartialUpdateMergeFunction {
if self.ignore_delete {
continue;
}
+ latest_row = Some(row);
if !saw_add && last_retract_source.is_none() {
// Java initializes the row from the first retract before
// applying sequence-group retractions. That row also
@@ -530,7 +562,7 @@ impl PartialUpdateMergeFunction {
}
last_retract_source = Some((row.batch_idx, row.row_idx));
current_delete_row = false;
- if !self.sequence_groups.is_empty() {
+ if self.sequence_group_enabled {
let mut full_delete = false;
for (group_idx, group) in
self.sequence_groups.iter().enumerate() {
let sequence_is_empty =
group.sequence_indices.iter().all(|&index| {
@@ -618,6 +650,7 @@ impl PartialUpdateMergeFunction {
}
saw_add = true;
current_delete_row = false;
+ latest_row = Some(row);
for (output_col_idx, selected) in
selected_by_col.iter_mut().enumerate() {
if self.grouped_fields.contains(&output_col_idx) {
@@ -712,6 +745,9 @@ impl PartialUpdateMergeFunction {
.iter()
.enumerate()
.map(|(output_col_idx, field)| {
+ if let Some(column) = merged_metadata_column(field,
latest_row.unwrap(), !saw_add) {
+ return Ok(column);
+ }
let column = match aggregators
.as_ref()
.and_then(|aggregators| aggregators.get(output_col_idx))
@@ -822,8 +858,8 @@ fn compare_sequence_group_rows(
/// Reference: Java
`org.apache.paimon.mergetree.compact.aggregate.AggregateMergeFunction`.
#[derive(Debug)]
pub(crate) struct AggregateMergeFunction {
- /// One slot per output column. `None` marks primary-key columns that are
- /// copied through; `Some` holds the aggregator that owns the column.
+ /// One slot per output column. `None` marks keys or KV metadata;
+ /// `Some` holds the aggregator that owns a user value column.
aggregators: Mutex<Vec<Option<Box<dyn FieldAggregator>>>>,
remove_record_on_delete: bool,
}
@@ -855,6 +891,12 @@ impl AggregateMergeFunction {
.iter()
.map(|field| -> crate::Result<Option<Box<dyn FieldAggregator>>> {
let name = field.name();
+ if matches!(
+ field.id(),
+ crate::spec::SEQUENCE_NUMBER_FIELD_ID |
crate::spec::VALUE_KIND_FIELD_ID
+ ) {
+ return Ok(None);
+ }
let agg_name: &str = if seq_set.contains(name) {
"last_value"
} else if pk_set.contains(name) {
@@ -939,15 +981,20 @@ impl AggregateMergeFunction {
}
}
- // Use the last sorted row to source primary-key column values: every
- // row in the group shares the same PK by construction, so any row
- // works; picking the last one keeps the slice cheap to compute.
+ // Java getResult uses the last KV for keys and record metadata.
let pk_source = rows.last().unwrap();
let output_columns: Vec<ArrayRef> = aggregators
.iter()
.enumerate()
.map(|(col_idx, slot)| -> crate::Result<ArrayRef> {
+ if let Some(column) = merged_metadata_column(
+ output_schema.field(col_idx),
+ pk_source,
+ current_delete_row,
+ ) {
+ return Ok(column);
+ }
if current_delete_row {
return Ok(batch_buffer[pk_source.batch_idx]
.column_for_output(col_idx, source_output_col_indices)
diff --git a/crates/paimon/src/table/stats_filter.rs
b/crates/paimon/src/table/stats_filter.rs
index 8c778b2b..accc320f 100644
--- a/crates/paimon/src/table/stats_filter.rs
+++ b/crates/paimon/src/table/stats_filter.rs
@@ -24,7 +24,8 @@ use crate::predicate_stats::{
predicates_may_match_with_schema, StatsAccessor,
};
use crate::spec::{
- extract_datum, is_row_id_column, BinaryRow, DataField, DataFileMeta,
DataType, Datum, Predicate,
+ extract_datum, is_row_tracking_column, BinaryRow, DataField, DataFileMeta,
DataType, Datum,
+ Predicate,
};
use std::collections::HashMap;
use std::sync::Arc;
@@ -601,8 +602,8 @@ fn data_evolution_predicate_may_match(
file_stats,
row_count,
),
- // `_ROW_ID` has no column stats, so never prune a group on it.
- Predicate::Leaf { column, .. } if is_row_id_column(column) => true,
+ // Row-tracking metadata has no logical column stats, so never prune a
group on it.
+ Predicate::Leaf { column, .. } if is_row_tracking_column(column) =>
true,
Predicate::Leaf {
index,
data_type,
@@ -667,7 +668,7 @@ fn data_evolution_predicate_must_match(
row_count,
),
// Stats cannot decide `_ROW_ID`, so it never provably matches.
- Predicate::Leaf { column, .. } if is_row_id_column(column) => false,
+ Predicate::Leaf { column, .. } if is_row_tracking_column(column) =>
false,
Predicate::Leaf {
index,
data_type,
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index f31d64fd..a6479071 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1012,6 +1012,11 @@ async fn prune_data_evolution_group_by_read_fields(
} else {
None
};
+ let sequence_idx = if read_field_ids.contains(&SEQUENCE_NUMBER_FIELD_ID) {
+ Some(data_evolution_sequence_file_index(&group)?)
+ } else {
+ None
+ };
let mut keep = Vec::with_capacity(group.len());
for (idx, file) in group.iter().enumerate() {
@@ -1035,6 +1040,11 @@ async fn prune_data_evolution_group_by_read_fields(
keep.push(anchor_idx);
}
}
+ if let Some(sequence_idx) = sequence_idx {
+ if !keep.contains(&sequence_idx) {
+ keep.push(sequence_idx);
+ }
+ }
if keep.is_empty() {
keep.push(data_evolution_representative_file(&group)?);
@@ -1047,6 +1057,10 @@ async fn prune_data_evolution_group_by_read_fields(
}
}
+ if sequence_idx.is_some() {
+ // Preserve original order for equal-sequence metadata providers.
+ keep.sort_unstable();
+ }
let mut files = group.into_iter().map(Some).collect::<Vec<_>>();
Ok(keep
.into_iter()
@@ -1054,6 +1068,21 @@ async fn prune_data_evolution_group_by_read_fields(
.collect())
}
+/// The newest normal file supplies row-tracking sequence metadata. It must
+/// survive user-column pruning even if it only wrote an unprojected column.
+fn data_evolution_sequence_file_index(group: &[DataFileMeta]) ->
crate::Result<usize> {
+ group
+ .iter()
+ .enumerate()
+ .filter(|(_, file)| is_normal_data_file(file))
+ .min_by_key(|(idx, file)|
(std::cmp::Reverse(file.max_sequence_number), *idx))
+ .map(|(idx, _)| idx)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Data-evolution sequence metadata requires a normal data
file".into(),
+ source: None,
+ })
+}
+
#[derive(Debug, Clone)]
pub struct TableScan<'a>(TableScanKind<'a>);
diff --git a/crates/paimon/tests/data_evolution_row_sidecar_test.rs
b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
index c8814e3b..b2d924ee 100644
--- a/crates/paimon/tests/data_evolution_row_sidecar_test.rs
+++ b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
@@ -249,7 +249,7 @@ async fn
selection_thresholds_are_inclusive_but_full_reads_keep_primary() {
}
#[tokio::test]
-async fn missing_sidecar_only_falls_back_when_ignore_lost_file_is_enabled() {
+async fn missing_sidecar_is_an_error_for_selected_reads() {
let table = table(&[]).await;
let messages = append(&table, 0, 100).await;
let file = &messages[0].new_files[0];
@@ -258,35 +258,17 @@ async fn
missing_sidecar_only_falls_back_when_ignore_lost_file_is_enabled() {
&file.extra_files[0],
);
table.file_io().delete_file(&sidecar).await.unwrap();
- let strict = table.copy_with_options(HashMap::from([(
- "scan.ignore-lost-files".into(),
- "false".into(),
- )]));
- for reader_table in [&table, &strict] {
- let error = read(reader_table, Some(vec![RowRange::new(5, 5)]))
- .await
- .unwrap_err();
- assert!(error.to_string().contains(".row"), "{error}");
- }
- let tolerant = table.copy_with_options(HashMap::from([(
- "scan.ignore-lost-files".into(),
- "true".into(),
- )]));
- assert_eq!(
- rows(
- &read(&tolerant, Some(vec![RowRange::new(5, 5)]))
- .await
- .unwrap()
- ),
- vec![(5, "v5".into(), 5)]
- );
+ let error = read(&table, Some(vec![RowRange::new(5, 5)]))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains(".row"), "{error}");
// Whole-file reads never require the auxiliary file.
assert_eq!(rows(&read(&table, None).await.unwrap()).len(), 100);
}
#[tokio::test]
-async fn corrupt_sidecar_is_not_hidden_by_ignore_lost_file() {
- let table = table(&[("scan.ignore-lost-files", "true")]).await;
+async fn corrupt_sidecar_is_an_error_for_selected_reads() {
+ let table = table(&[]).await;
let messages = append(&table, 0, 100).await;
let file = &messages[0].new_files[0];
let path = file.aligned_file_path(
diff --git a/crates/paimon/tests/data_evolution_system_columns_test.rs
b/crates/paimon/tests/data_evolution_system_columns_test.rs
new file mode 100644
index 00000000..d87847fc
--- /dev/null
+++ b/crates/paimon/tests/data_evolution_system_columns_test.rs
@@ -0,0 +1,599 @@
+// 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.
+
+mod common;
+
+use arrow_array::{Array, ArrayRef, Int32Array, Int64Array, Int8Array,
RecordBatch};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{
+ BigIntType, DataField, DataType, Datum, IntType, Predicate,
PredicateOperator, Schema,
+ TableSchema,
+};
+use paimon::table::Table;
+use std::sync::Arc;
+
+async fn table() -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("v", DataType::Int(IntType::new()))
+ .column("w", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ create_table(schema).await
+}
+
+async fn create_table(schema: Schema) -> Table {
+ let (io, table) = memory_table("memory:/system_columns",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ table
+}
+
+async fn append(table: &Table, start: i32) {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![start, start + 1])) as ArrayRef,
+ ),
+ ("v", Arc::new(Int32Array::from(vec![10, 20])) as ArrayRef),
+ ("w", Arc::new(Int32Array::from(vec![100, 200])) as ArrayRef),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ writer.close().await;
+ builder.new_commit().commit(messages).await.unwrap();
+}
+
+async fn update(table: &Table, field: &str, row_id: i64, value: i32) {
+ let builder = table.new_write_builder();
+ let messages = builder
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![RecordBatch::try_from_iter([
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![row_id])) as ArrayRef,
+ ),
+ (field, Arc::new(Int32Array::from(vec![value])) as ArrayRef),
+ ])
+ .unwrap()])
+ .await
+ .unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+}
+
+fn sequence_field() -> DataField {
+ DataField::new(
+ paimon::spec::SEQUENCE_NUMBER_FIELD_ID,
+ "_SEQUENCE_NUMBER".into(),
+ DataType::BigInt(BigIntType::with_nullable(false)),
+ )
+}
+
+async fn sequences(table: &Table) -> paimon::Result<Vec<i64>> {
+ let mut builder = table.new_read_builder();
+ builder.with_read_type(vec![sequence_field()]);
+ let plan = builder.new_scan().plan().await?;
+ let batches: Vec<_> = builder
+ .new_read()?
+ .to_arrow(plan.splits())?
+ .try_collect()
+ .await?;
+ let mut values = Vec::new();
+ for batch in batches {
+ let column = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(column.null_count(), 0);
+ values.extend(column.values().iter().copied());
+ }
+ values.sort_unstable();
+ Ok(values)
+}
+
+#[tokio::test]
+async fn partial_files_supply_the_latest_sequence_without_a_physical_column() {
+ let table = table().await;
+ append(&table, 0).await;
+ assert_eq!(sequences(&table).await.unwrap(), vec![1, 1]);
+ update(&table, "v", 0, 77).await;
+ assert_eq!(sequences(&table).await.unwrap(), vec![2, 2]);
+ update(&table, "w", 1, 88).await;
+ assert_eq!(sequences(&table).await.unwrap(), vec![3, 3]);
+}
+
+fn sequence_predicate(op: PredicateOperator, values: &[i64]) -> Predicate {
+ // A system column has no position in the logical schema. This deliberately
+ // aliases the id column to catch accidental index-based stats/filtering.
+ Predicate::Leaf {
+ column: "_SEQUENCE_NUMBER".into(),
+ index: 0,
+ data_type: DataType::BigInt(BigIntType::with_nullable(false)),
+ op,
+ literals: values.iter().map(|v| Datum::Long(*v)).collect(),
+ }
+}
+
+async fn matching_ids(table: &Table, predicate: Predicate, project_sequence:
bool) -> Vec<i32> {
+ let mut builder = table.new_read_builder();
+ let projection = if project_sequence {
+ vec!["id", "_SEQUENCE_NUMBER"]
+ } else {
+ vec!["id"]
+ };
+ builder
+ .with_projection(&projection)
+ .unwrap()
+ .with_filter(predicate);
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut ids = Vec::new();
+ for batch in batches {
+ assert_eq!(batch.num_columns(), projection.len());
+ let values = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ ids.extend(values.values().iter().copied());
+ }
+ ids.sort_unstable();
+ ids
+}
+
+#[tokio::test]
+async fn sequence_predicates_use_metadata_before_and_after_partial_updates() {
+ let table = table().await;
+ append(&table, 100).await;
+ append(&table, 200).await;
+ for project in [false, true] {
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[1]),
+ project
+ )
+ .await,
+ vec![100, 101]
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[2]),
+ project
+ )
+ .await,
+ vec![200, 201]
+ );
+ }
+ update(&table, "v", 0, 77).await;
+ for project in [false, true] {
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[3]),
+ project
+ )
+ .await,
+ vec![100, 101]
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Lt, &[3]),
+ project
+ )
+ .await,
+ vec![200, 201]
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::IsNull, &[]),
+ project
+ )
+ .await,
+ Vec::<i32>::new()
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::IsNotNull, &[]),
+ project
+ )
+ .await,
+ vec![100, 101, 200, 201]
+ );
+ }
+ let id = paimon::spec::PredicateBuilder::new(table.schema().fields())
+ .equal("id", Datum::Int(200))
+ .unwrap();
+ assert_eq!(
+ matching_ids(
+ &table,
+ Predicate::or(vec![sequence_predicate(PredicateOperator::Eq,
&[3]), id]),
+ false
+ )
+ .await,
+ vec![100, 101, 200]
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ Predicate::Not(Box::new(sequence_predicate(PredicateOperator::Eq,
&[3]))),
+ false
+ )
+ .await,
+ vec![200, 201]
+ );
+}
+
+#[tokio::test]
+async fn physical_versions_are_kept_and_null_versions_use_manifest_metadata() {
+ use parquet::arrow::ArrowWriter;
+ let table = table().await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![100, 101])) as ArrayRef),
+ ("v", Arc::new(Int32Array::from(vec![10, 20])) as ArrayRef),
+ ("w", Arc::new(Int32Array::from(vec![100, 200])) as ArrayRef),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let mut messages = writer.prepare_commit().await.unwrap();
+ writer.close().await;
+ let schema = batch.schema();
+ let mut columns: Vec<_> = schema
+ .fields()
+ .iter()
+ .zip(batch.columns())
+ .map(|(field, array)| (field.name().as_str(), array.clone()))
+ .collect();
+ columns.push((
+ "_SEQUENCE_NUMBER",
+ Arc::new(Int64Array::from(vec![Some(7), None])) as ArrayRef,
+ ));
+ let physical = RecordBatch::try_from_iter(columns).unwrap();
+ let mut parquet = ArrowWriter::try_new(Vec::new(), physical.schema(),
None).unwrap();
+ parquet.write(&physical).unwrap();
+ let data = bytes::Bytes::from(parquet.into_inner().unwrap());
+ let file = &mut messages[0].new_files[0];
+ file.file_size = data.len() as i64;
+ table
+ .file_io()
+ .new_output(&format!("{}/bucket-0/{}", table.location(),
file.file_name))
+ .unwrap()
+ .write(data)
+ .await
+ .unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(sequences(&table).await.unwrap(), vec![1, 7]);
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[7]),
+ false
+ )
+ .await,
+ vec![100]
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[1]),
+ false
+ )
+ .await,
+ vec![101]
+ );
+}
+
+#[tokio::test]
+async fn blob_versions_cannot_replace_the_normal_metadata_provider() {
+ use arrow_array::LargeBinaryArray;
+ use paimon::spec::{BinaryRow, BlobType};
+ use paimon::table::DataSplitBuilder;
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let table = create_table(schema).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![100, 101])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(b"a".as_slice()),
+ Some(b"b".as_slice()),
+ ])) as ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ writer.close().await;
+ builder.new_commit().commit(messages).await.unwrap();
+ let mut files = table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()[0]
+ .data_files()
+ .to_vec();
+ let blob = files
+ .iter_mut()
+ .find(|file| file.file_name.ends_with(".blob"))
+ .unwrap();
+ blob.max_sequence_number = 99;
+ // An unrequested payload file must not be read just to supply metadata.
+ table
+ .file_io()
+ .delete_file(&format!("{}/bucket-0/{}", table.location(),
blob.file_name))
+ .await
+ .unwrap();
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_total_buckets(1)
+ .with_bucket_path(format!("{}/bucket-0", table.location()))
+ .with_data_files(files)
+ .build()
+ .unwrap();
+ let mut read = table.new_read_builder();
+ read.with_projection(&["id", "_SEQUENCE_NUMBER"]).unwrap();
+ let batches: Vec<_> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(&[split])
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
+ for batch in batches {
+ let seq = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(seq.values().as_ref(), &[1, 1]);
+ }
+}
+
+#[tokio::test]
+async fn primary_key_metadata_never_uses_the_user_aggregate_function() {
+ for engine in ["deduplicate", "first-row", "partial-update",
"aggregation"] {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("v", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("merge-engine", engine);
+ if engine == "aggregation" {
+ schema = schema.option("fields.default-aggregate-function", "sum");
+ }
+ let schema = schema.build().unwrap();
+ let table = create_table(schema).await;
+ let table = if engine == "first-row" {
+ // A Java pk-clustering table permits first-row DVs. Reproduce the
+ // resolved catalog options and read level-0 files without
compaction.
+ table.copy_with_options(std::collections::HashMap::from([
+ ("deletion-vectors.enabled".into(), "true".into()),
+ ("deletion-vectors.merge-on-read".into(), "true".into()),
+ ("pk-clustering-override".into(), "true".into()),
+ ]))
+ } else {
+ table
+ };
+ for _ in 0..3 {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![100])) as
ArrayRef),
+ ("v", Arc::new(Int32Array::from(vec![10])) as
ArrayRef),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ writer.close().await;
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+ let sequence = if engine == "first-row" { 0 } else { 2 };
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[sequence]),
+ false
+ )
+ .await,
+ vec![100]
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::NotEq, &[sequence]),
+ true
+ )
+ .await,
+ Vec::<i32>::new()
+ );
+ let mut reader = table.new_read_builder();
+ reader.with_projection(&["v", "_SEQUENCE_NUMBER"]).unwrap();
+ let plan = reader.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = reader
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.len(), 1);
+ let value = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(
+ value.value(0),
+ if engine == "aggregation" { 30 } else { 10 }
+ );
+ let seq = batches[0]
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(seq.value(0), sequence);
+ }
+}
+
+async fn retract_metadata(engine: &str) {
+ for kind in [1_i8, 3] {
+ for one_file in [false, true] {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("g", DataType::Int(IntType::new()))
+ .column("v", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("merge-engine", engine);
+ schema = if engine == "aggregation" {
+ schema
+ .option("fields.default-aggregate-function", "sum")
+ .option("aggregation.remove-record-on-delete", "false")
+ } else {
+ schema.option("fields.g.sequence-group", "v")
+ };
+ let table = create_table(schema.build().unwrap()).await;
+ let operations = [(1, 10, 0_i8), (2, 5, kind)];
+ for rows in operations.chunks(if one_file { 2 } else { 1 }) {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![100;
rows.len()])) as ArrayRef,
+ ),
+ (
+ "g",
+ Arc::new(Int32Array::from(
+ rows.iter().map(|r|
r.0).collect::<Vec<_>>(),
+ )) as ArrayRef,
+ ),
+ (
+ "v",
+ Arc::new(Int32Array::from(
+ rows.iter().map(|r|
r.1).collect::<Vec<_>>(),
+ )) as ArrayRef,
+ ),
+ (
+ "_VALUE_KIND",
+ Arc::new(Int8Array::from(
+ rows.iter().map(|r|
r.2).collect::<Vec<_>>(),
+ )) as ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ writer.close().await;
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+ assert_eq!(
+ sequences(&table).await.unwrap(),
+ vec![1],
+ "{engine}, kind={kind}, one_file={one_file}"
+ );
+ assert_eq!(
+ matching_ids(
+ &table,
+ sequence_predicate(PredicateOperator::Eq, &[1]),
+ false
+ )
+ .await,
+ vec![100]
+ );
+ let mut reader = table.new_read_builder();
+ reader.with_projection(&["v", "_SEQUENCE_NUMBER"]).unwrap();
+ let plan = reader.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = reader
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let values = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ if engine == "aggregation" {
+ assert_eq!(values.value(0), 5);
+ } else {
+ assert!(values.is_null(0));
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn aggregation_retractions_keep_the_latest_sequence() {
+ retract_metadata("aggregation").await;
+}
+
+#[tokio::test]
+async fn partial_update_retractions_keep_the_latest_sequence() {
+ retract_metadata("partial-update").await;
+}