This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 62cd57fcee [Python] Enable native row-tracking sequence reads and
predicates (#10408)
62cd57fcee is described below
commit 62cd57fceec2f9318f254051984487502caf5244
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 09:33:56 2026 +0800
[Python] Enable native row-tracking sequence reads and predicates (#10408)
---
.../pypaimon/read/reader/data_file_batch_reader.py | 5 +-
paimon-python/pypaimon/read/split_read.py | 12 +-
paimon-python/pypaimon/read/table_read.py | 16 --
.../tests/native_data_evolution_commit_test.py | 5 +-
paimon-python/pypaimon/tests/native_read_test.py | 21 ---
.../pypaimon/tests/native_row_sidecar_test.py | 13 +-
.../pypaimon/tests/native_system_columns_test.py | 197 +++++++++++++++++++++
7 files changed, 216 insertions(+), 53 deletions(-)
diff --git a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
index 4952692b2c..b50842299f 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -469,8 +469,9 @@ class DataFileBatchReader(RecordBatchReader):
# Handle _SEQUENCE_NUMBER field
if SpecialFields.SEQUENCE_NUMBER.name in self.system_fields.keys():
idx = self.system_fields[SpecialFields.SEQUENCE_NUMBER.name]
- # Create a new array that fills with max_sequence_number
- arrays[idx] = pa.repeat(self.max_sequence_number,
record_batch.num_rows)
+ # Java's tracking vector retains physical versions and only fills
+ # NULL entries from the manifest's max sequence number.
+ arrays[idx] = pc.fill_null(arrays[idx], self.max_sequence_number)
names = record_batch.schema.names
table = None
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index 80aeb562f1..d0e875426d 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -159,7 +159,7 @@ class SplitRead(ABC):
self.predicate = predicate
self.push_down_predicate = self._push_down_predicate()
self._arrow_filter_pushdown_enabled = predicate_supports_arrow_filter(
- self.push_down_predicate)
+ self.push_down_predicate) and not
self._has_row_tracking_predicate()
self.split = split
self.row_tracking_enabled = row_tracking_enabled
self.value_arity = len(read_type)
@@ -254,6 +254,10 @@ class SplitRead(ABC):
def _push_down_predicate(self) -> Optional[Predicate]:
if self.predicate is None:
return None
+ elif self._has_row_tracking_predicate():
+ # Metadata is assigned after file decoding. Filtering here would
+ # see missing/NULL versions and renumber positional row IDs.
+ return None
elif self.table.is_primary_key_table:
pk_predicate = trim_predicate_by_fields(self.predicate,
self.table.primary_keys)
if not pk_predicate:
@@ -262,6 +266,12 @@ class SplitRead(ABC):
else:
return self.predicate
+ def _has_row_tracking_predicate(self) -> bool:
+ return (self.table.options.row_tracking_enabled()
+ and self.predicate is not None
+ and bool(predicate_field_names(self.predicate) & {
+ SpecialFields.ROW_ID.name,
SpecialFields.SEQUENCE_NUMBER.name}))
+
@abstractmethod
def create_reader(self) -> RecordReader:
"""Create a record reader for the given split."""
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 0c572ef921..551d0300be 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -519,10 +519,6 @@ class TableRead:
if any(isinstance(split, QueryAuthSplit) for split in splits):
return self._native_fallback(
"query authorization requires the Python reader")
- if not self._native_data_evolution_sequence_supported(splits):
- return self._native_fallback(
- "sequence-number projection over partial data-evolution files "
- "requires the Python reader")
try:
from pypaimon.read.native_plan import (
_prepare_native_read, _raise_if_native_fork_safety_error,
@@ -815,18 +811,6 @@ class TableRead:
return False
return True
- def _native_data_evolution_sequence_supported(self, splits):
- from pypaimon.table.special_fields import SpecialFields
-
- if (not self.table.options.data_evolution_enabled()
- or not any(field.id == SpecialFields.SEQUENCE_NUMBER.id for
field in self._scan_read_type)):
- return True
- # Current Rust provider selection cannot synthesize the sequence
- # column from metadata when partial files omit that physical column.
- return all(data_file.write_cols is None
- or SpecialFields.SEQUENCE_NUMBER.name in
data_file.write_cols
- for split in splits for data_file in split.files)
-
def _convert_native_batches(self, batches, schema):
remaining = self.limit
try:
diff --git a/paimon-python/pypaimon/tests/native_data_evolution_commit_test.py
b/paimon-python/pypaimon/tests/native_data_evolution_commit_test.py
index e789ff4ebe..344aa2ff83 100644
--- a/paimon-python/pypaimon/tests/native_data_evolution_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_data_evolution_commit_test.py
@@ -75,10 +75,7 @@ def _read(table, native=False, metadata=False):
'scan.native-plan.enabled': str(native).lower()})
builder = read_table.new_read_builder()
if metadata:
- fields = ['id', 'v', 'w', 'p', '_ROW_ID']
- if not native:
- fields.append('_SEQUENCE_NUMBER')
- builder.with_projection(fields)
+ builder.with_projection(['id', 'v', 'w', 'p', '_ROW_ID',
'_SEQUENCE_NUMBER'])
read = builder.new_read()
with ExitStack() as stack:
if native:
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index e01e95ede3..b50c9c15e4 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -89,27 +89,6 @@ def
test_native_read_falls_back_before_opening_primary_file_with_row_sidecar():
native.assert_not_called()
[email protected]('project_sequence', [False, True])
-def
test_native_partial_data_evolution_read_requires_a_sequence_provider(project_sequence):
- from pypaimon.table.special_fields import SpecialFields
-
- read = _table_read()
- read.table.options.data_evolution_enabled.return_value = True
- if project_sequence:
- read._scan_read_type += [SpecialFields.SEQUENCE_NUMBER]
- split = _Split()
- split._native_split = object()
- split.files[0].write_cols = ['id']
- with patch('pypaimon.read.native_plan.native_read',
return_value=[_id_batch([1])]) as native:
- batches = read._try_native_batches([split], pa.schema([('id',
pa.int32())]))
- if project_sequence:
- assert batches is None
- native.assert_not_called()
- else:
- assert [batch.to_pydict() for batch in batches] == [{'id': [1]}]
- native.assert_called_once()
-
-
def test_native_read_returns_named_variant_expression_columns():
read = _table_read()
variants = {
diff --git a/paimon-python/pypaimon/tests/native_row_sidecar_test.py
b/paimon-python/pypaimon/tests/native_row_sidecar_test.py
index 4241aad7e2..510bd6bd05 100644
--- a/paimon-python/pypaimon/tests/native_row_sidecar_test.py
+++ b/paimon-python/pypaimon/tests/native_row_sidecar_test.py
@@ -216,19 +216,14 @@ def
test_sidecars_store_logical_nested_map_and_variant_values(tmp_path, layout):
assert _read(table, projection, row_id).to_pydict() == expected
[email protected]('enabled', [None, 'false', 'true'])
-def test_missing_sidecar_can_fall_back_to_native_parquet(tmp_path, enabled):
- options = {} if enabled is None else {'scan.ignore-lost-files': enabled}
- table = _table(tmp_path, options=options)
+def test_missing_selected_sidecar_reports_its_path(tmp_path):
+ table = _table(tmp_path)
messages = _append(table)
for message in messages:
for file in message.new_files:
os.remove(next(path for path in file.collect_files() if
path.endswith('.row')))
- if enabled == 'true':
- assert _read(table, ['id', 'value'], 5).to_pydict() == {'id': [5],
'value': ['v5']}
- else:
- with pytest.raises(ValueError, match=r'parquet\.row'):
- _read(table, ['id', 'value'], 5)
+ with pytest.raises(ValueError, match=r'parquet\.row'):
+ _read(table, ['id', 'value'], 5)
@pytest.mark.parametrize('enabled', ['false', 'off', 'true', 'on'])
diff --git a/paimon-python/pypaimon/tests/native_system_columns_test.py
b/paimon-python/pypaimon/tests/native_system_columns_test.py
new file mode 100644
index 0000000000..8ba62eab7a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_system_columns_test.py
@@ -0,0 +1,197 @@
+# 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.
+
+"""Row-tracking metadata and filters use Java's data-file versions."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('v', pa.int32()), ('w', pa.int32())])
+
+
+def _create_table(tmp_path, schema, options):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
options=options), False)
+ return catalog.get_table('default.t')
+
+
+def _table(tmp_path, evolution, native_write=True, deletion_vectors=False):
+ options = {'row-tracking.enabled': 'true', 'data-evolution.enabled':
str(evolution).lower(),
+ 'write.native.enabled': str(native_write).lower(),
+ 'deletion-vectors.enabled': str(deletion_vectors).lower()}
+ table = _create_table(tmp_path, _SCHEMA, options)
+ for start in (100, 200):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': [start, start + 1], 'v': [10,
20],
+ 'w': [100, 200]}, schema=_SCHEMA))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ return table
+
+
+def _update(table, field, row_id, value):
+ builder = table.new_batch_write_builder()
+ messages = builder.new_update().update_by_arrow_with_row_id(pa.table({
+ '_ROW_ID': pa.array([row_id], pa.int64()), field: pa.array([value],
pa.int32())}))
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+
+
+def _read(table, native, projection, predicate=None, limit=None):
+ copy = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ builder = copy.new_read_builder().with_projection(projection)
+ if predicate is not None:
+ builder.with_filter(predicate)
+ if limit is not None:
+ builder.with_limit(limit)
+ reader = builder.new_read()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(reader, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ return reader.to_arrow(builder.new_scan().plan().splits(),
parallelism=1)
+
+
[email protected]('native', [False, True])
[email protected]('native_write', [False, True])
[email protected]('deletion_vectors', [False, True])
+def test_partial_column_updates_keep_latest_metadata_provider(tmp_path,
native, native_write, deletion_vectors):
+ table = _table(tmp_path, True, native_write, deletion_vectors)
+ _update(table, 'v', 0, 77)
+ _update(table, 'w', 1, 88)
+ for projection in (['_SEQUENCE_NUMBER'], ['id', '_SEQUENCE_NUMBER'],
+ ['_SEQUENCE_NUMBER', 'w', '_ROW_ID', 'v', 'id']):
+ result = _read(table, native, projection)
+ assert result.column_names == projection
+ assert not result.schema.field('_SEQUENCE_NUMBER').nullable
+ assert result['_SEQUENCE_NUMBER'].null_count == 0
+ if 'id' in projection:
+ result = result.sort_by('id')
+ assert result['id'].to_pylist() == [100, 101, 200, 201]
+ assert result['_SEQUENCE_NUMBER'].to_pylist() == [4, 4, 2, 2]
+ if 'v' in projection:
+ assert result['v'].to_pylist() == [77, 20, 10, 20]
+ assert result['w'].to_pylist() == [100, 88, 100, 200]
+ assert result['_ROW_ID'].to_pylist() == [0, 1, 2, 3]
+ else:
+ assert sorted(result['_SEQUENCE_NUMBER'].to_pylist()) == [2, 2, 4,
4]
+
+
[email protected]('native', [False, True])
[email protected]('evolution', [False, True])
+def test_metadata_predicates_with_unprojected_columns(tmp_path, native,
evolution):
+ table = _table(tmp_path, evolution)
+ if evolution:
+ _update(table, 'v', 0, 77)
+ latest = 3 if evolution else 1
+ pb = table.new_read_builder().with_projection(['id', 'v', '_ROW_ID',
'_SEQUENCE_NUMBER']).new_predicate_builder()
+ cases = [
+ (pb.equal('_SEQUENCE_NUMBER', latest), [100, 101]),
+ (pb.is_null('_SEQUENCE_NUMBER'), []),
+ (pb.is_not_null('_SEQUENCE_NUMBER'), [100, 101, 200, 201]),
+ (pb.and_predicates([pb.equal('_SEQUENCE_NUMBER', latest),
pb.equal('id', 101)]), [101]),
+ (pb.or_predicates([pb.equal('_SEQUENCE_NUMBER', latest),
pb.equal('id', 200)]), [100, 101, 200]),
+ (pb.or_predicates([pb.equal('_SEQUENCE_NUMBER', 2),
pb.equal('_ROW_ID', 0)]), [100, 200, 201]),
+ ]
+ for predicate, expected in cases:
+ result = _read(table, native, ['id'], predicate).sort_by('id')
+ assert result.column_names == ['id']
+ assert result['id'].to_pylist() == expected
+ result = _read(table, native, ['id'], pb.equal('_SEQUENCE_NUMBER',
latest), limit=1)
+ assert result.num_rows == 1
+ assert result['id'][0].as_py() in [100, 101]
+
+
[email protected]('native', [False, True])
+def test_physical_versions_override_manifest_only_when_not_null(tmp_path,
native):
+ from dataclasses import replace
+ from pathlib import Path
+ import pyarrow.parquet as pq
+
+ table = _create_table(tmp_path, _SCHEMA, {
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'write.native.enabled': 'true'})
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ data = pa.table({'id': [100, 101], 'v': [10, 20], 'w': [100, 200]},
schema=_SCHEMA)
+ writer.write_arrow(data)
+ messages = writer.prepare_commit()
+ file = messages[0].new_files[0]
+ data = data.append_column('_SEQUENCE_NUMBER', pa.array([7, None],
pa.int64()))
+ pq.write_table(data, file.file_path)
+ messages[0].new_files[0] = replace(file,
file_size=Path(file.file_path).stat().st_size)
+ commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+ result = _read(table, native, ['id', '_SEQUENCE_NUMBER']).sort_by('id')
+ assert result['_SEQUENCE_NUMBER'].to_pylist() == [7, 1]
+ pb = table.new_read_builder().with_projection(['id',
'_SEQUENCE_NUMBER']).new_predicate_builder()
+ assert _read(table, native, ['id'], pb.equal('_SEQUENCE_NUMBER',
7))['id'].to_pylist() == [100]
+ assert _read(table, native, ['id'], pb.equal('_SEQUENCE_NUMBER',
1))['id'].to_pylist() == [101]
+
+
[email protected]('native', [False, True])
+def test_case_sensitive_user_column_does_not_shadow_metadata(tmp_path, native):
+ schema = pa.schema([('id', pa.int32()), ('_sequence_number', pa.int32())])
+ table = _create_table(tmp_path, schema, {
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'write.native.enabled': 'true'})
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': [100, 101], '_sequence_number':
[40, 50]}, schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ projection = ['id', '_sequence_number', '_SEQUENCE_NUMBER']
+ pb =
table.new_read_builder().with_projection(projection).new_predicate_builder()
+ predicate = pb.and_predicates([pb.equal('_SEQUENCE_NUMBER', 1),
pb.equal('_sequence_number', 50)])
+ assert _read(table, native, projection, predicate).to_pydict() == {
+ 'id': [101], '_sequence_number': [50], '_SEQUENCE_NUMBER': [1]}
+
+
[email protected]('user_field,metadata', [
+ ('_sequence_number', '_SEQUENCE_NUMBER'), ('_row_id', '_ROW_ID')])
+def test_insensitive_metadata_name_collision_is_ambiguous(tmp_path,
user_field, metadata):
+ from pypaimon.write.native_commit import create_native_write_table
+
+ table = _create_table(tmp_path, pa.schema([(user_field, pa.int32())]), {
+ 'row-tracking.enabled': 'true', 'write.native.enabled': 'true'})
+ reader =
create_native_write_table(table).new_read_builder().with_case_sensitive(False)
+ for name in (metadata, user_field):
+ with pytest.raises(ValueError, match='Ambiguous'):
+ reader.with_projection([name]).new_read().read_arrow([])
+ with pytest.raises(ValueError, match='Ambiguous'):
+ reader.with_filter({'method': 'equal', 'field': name, 'literals':
[1]})