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 ceb5c8f9e2 [python] Record correct delete row counts for written files
(#10435)
ceb5c8f9e2 is described below
commit ceb5c8f9e2e073b0a4198e450b70142c30e8e0b8
Author: zhigang <[email protected]>
AuthorDate: Thu Oct 8 16:04:15 2026 +0800
[python] Record correct delete row counts for written files (#10435)
---
.../pypaimon/tests/write/delete_row_count_test.py | 152 +++++++++++++++++++++
paimon-python/pypaimon/write/writer/data_writer.py | 16 ++-
.../pypaimon/write/writer/postpone_data_writer.py | 11 +-
3 files changed, 169 insertions(+), 10 deletions(-)
diff --git a/paimon-python/pypaimon/tests/write/delete_row_count_test.py
b/paimon-python/pypaimon/tests/write/delete_row_count_test.py
new file mode 100644
index 0000000000..0e791cb6f9
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/delete_row_count_test.py
@@ -0,0 +1,152 @@
+# 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.
+
+"""Deletion metadata must describe physical files, not incoming requests."""
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+from pyarrow import orc
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
+from pypaimon.read.split import DataSplit
+
+pytestmark = [pytest.mark.python_write, pytest.mark.python_plan,
+ pytest.mark.python_read, pytest.mark.python_commit]
+
+
+def make_table(tmp_path, fmt, options=None):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ settings = {'bucket': '1', 'file.format': fmt, 'rowkind.field': 'op',
+ 'changelog-producer': 'input', 'changelog-file.format': fmt,
+ 'write.native.enabled': 'false', 'read.native.enabled':
'false',
+ 'scan.native-plan.enabled': 'false', 'commit.native.enabled':
'false'}
+ settings.update(options or {})
+ schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
+ pa.field('v', pa.int32()), pa.field('op',
pa.string())])
+ catalog.create_table('default.events', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'], options=settings), False)
+ return catalog.get_table('default.events'), schema
+
+
+def commit(table, schema, rows):
+ builder = table.new_batch_write_builder()
+ writer, committer = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ messages = writer.prepare_commit()
+ committer.commit(messages)
+ return messages
+ finally:
+ writer.close()
+ committer.close()
+
+
+def check_metadata(table, messages):
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ lists = ManifestListManager(table)
+ manifests = ManifestFileManager(table)
+ for attr, stored in [('new_files', lists.read_delta(snapshot)),
+ ('changelog_files', lists.read_changelog(snapshot))]:
+ prepared = {f.file_name: f for m in messages for f in getattr(m, attr)}
+ restored = {entry.file.file_name: entry.file for manifest in stored
+ for entry in manifests.read(manifest.file_name)}
+ assert restored.keys() == prepared.keys()
+ for name, meta in prepared.items():
+ with table.file_io.new_input_stream(meta.file_path) as source:
+ physical = (pq.ParquetFile(source).read() if
name.endswith('.parquet')
+ else orc.ORCFile(source).read())
+ deletes = sum(kind in (1, 3) for kind in
physical['_VALUE_KIND'].to_pylist())
+ assert meta.row_count == restored[name].row_count ==
physical.num_rows
+ assert meta.delete_row_count == restored[name].delete_row_count ==
deletes
+
+
+def read_rows(table):
+ builder = table.new_read_builder()
+ splits = builder.new_scan().plan().splits()
+ return builder.new_read().to_arrow(splits).to_pylist(), splits
+
+
[email protected]('fmt', ['parquet', 'orc'])
[email protected]('op,deletes', [('+I', 0), ('+U', 0), ('-U', 1),
('-D', 1)])
+def test_single_file_retractions_are_not_visible(tmp_path, fmt, op, deletes):
+ table, schema = make_table(tmp_path, fmt)
+ rows = [{'id': 1, 'v': 10, 'op': op}]
+ messages = commit(table, schema, rows)
+ table = CatalogFactory.create({'warehouse':
str(tmp_path)}).get_table('default.events')
+ actual, splits = read_rows(table)
+ assert actual == ([] if deletes else rows)
+ check_metadata(table, messages)
+ assert [f.delete_row_count for m in messages for f in m.new_files] ==
[deletes]
+ assert len(splits) == 1
+ assert isinstance(splits[0], DataSplit)
+ assert splits[0].raw_convertible == (deletes == 0)
+
+
[email protected]('fmt', ['parquet', 'orc'])
+def test_counts_follow_folded_and_rolled_files(tmp_path, fmt):
+ table, schema = make_table(tmp_path, fmt, {'target-file-size': '40 b'})
+ # Three retract requests become two physical retracts after the id=1
update.
+ rows = [{'id': key, 'v': value, 'op': op} for key, value, op in [
+ (1, 10, '-D'), (1, 20, '+U'), (2, 30, '+I'),
+ (2, 30, '-U'), (3, 40, '-D'), (4, 50, '+I')]]
+ messages = commit(table, schema, rows)
+ table = CatalogFactory.create({'warehouse':
str(tmp_path)}).get_table('default.events')
+ actual, _ = read_rows(table)
+ assert sorted(actual, key=lambda row: row['id']) == [rows[1], rows[-1]]
+ # A point lookup can prune the other rolled files and expose a
retract-only file.
+ reader = table.new_read_builder()
+ reader.with_filter(reader.new_predicate_builder().equal('id', 3))
+ splits = reader.new_scan().plan().splits()
+ result = reader.new_read().to_arrow(splits)
+ assert result is not None
+ assert result.num_rows == 0
+ files = [f for m in messages for f in m.new_files]
+ assert len(files) > 1
+ assert sum(f.row_count for f in files) == 4
+ delete_counts = []
+ for file in files:
+ assert file.delete_row_count is not None
+ delete_counts.append(file.delete_row_count)
+ assert sum(delete_counts) == 2
+ assert any(f.delete_row_count == 0 for f in files)
+ check_metadata(table, messages)
+
+
[email protected]('fmt', ['parquet', 'orc'])
+def test_delete_survives_reopening_table(tmp_path, fmt):
+ table, schema = make_table(tmp_path, fmt)
+ rows = [{'id': 1, 'v': 10, 'op': '+I'}, {'id': 2, 'v': 20, 'op': '+I'}]
+ added = commit(table, schema, rows)
+ check_metadata(table, added)
+ assert sorted(read_rows(table)[0], key=lambda row: row['id']) == rows
+ deleted = commit(table, schema, [{'id': 1, 'v': 10, 'op': '-D'}])
+
+ # Open a fresh catalog/table: the delete must survive through persisted
+ # snapshots and manifests, without affecting the other primary key.
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ table = catalog.get_table('default.events')
+ actual, splits = read_rows(table)
+ assert actual == [rows[1]]
+ check_metadata(table, deleted)
+ # The earlier add file remains in the latest snapshot; its physical count
+ # stays zero even though the row no longer exists in the logical table.
+ counts = {f.file_name: f.delete_row_count for split in splits for f in
split.files}
+ assert counts == {added[0].new_files[0].file_name: 0,
+ deleted[0].new_files[0].file_name: 1}
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py
b/paimon-python/pypaimon/write/writer/data_writer.py
index 57d1dbbbe7..4d78940cdd 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -29,6 +29,7 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.row_kind import RowKind
from pypaimon.utils.file_store_path_factory import canonical_data_file_path
from pypaimon.write.map_shared_shredding_writer import MapSharedShreddingWriter
from pypaimon.write.writer.mosaic_writer_options import
create_mosaic_writer_options
@@ -364,6 +365,7 @@ class DataWriter(ABC):
max_sequence_number=max_seq,
extra_files=extra_files,
creation_time=creation_time,
+ delete_row_count=self._count_delete_rows(data),
)
if self.changelog_producer == ChangelogProducer.INPUT:
@@ -402,7 +404,7 @@ class DataWriter(ABC):
def _create_data_file_meta(self, file_name, file_path, row_count,
min_key, max_key, key_stats, value_stats,
min_sequence_number, max_sequence_number,
- extra_files=None, creation_time=None):
+ extra_files=None, creation_time=None,
delete_row_count=0):
"""Common metadata finalization for buffered and incremental files."""
return DataFileMeta.create(
file_name=file_name,
@@ -415,12 +417,20 @@ class DataWriter(ABC):
schema_id=self.table.table_schema.id, level=0,
extra_files=extra_files if extra_files is not None else [],
creation_time=creation_time if creation_time is not None else
Timestamp.now(),
- delete_row_count=0, file_source=0,
+ delete_row_count=delete_row_count, file_source=0,
value_stats_cols=None if self.options.metadata_stats_enabled()
else [],
external_path=file_path if self.external_path_provider is not None
else None,
first_row_id=None, write_cols=self.write_cols, file_path=file_path,
)
+ def _count_delete_rows(self, data: pa.Table) -> int:
+ # Count the final file contents, after merging and rolling the buffer.
+ if not self.table.is_primary_key_table or '_VALUE_KIND' not in
data.schema.names:
+ return 0
+ kind_counts = pc.value_counts(data.column('_VALUE_KIND'))
+ return sum(entry['counts'].as_py() for entry in kind_counts
+ if entry['values'].as_py() in (RowKind.UPDATE_BEFORE.value,
RowKind.DELETE.value))
+
def _apply_variant_shredding(self, data: pa.Table) -> pa.Table:
"""Transform VARIANT columns into shredded Parquet format.
@@ -483,7 +493,7 @@ class DataWriter(ABC):
level=0,
extra_files=[],
creation_time=creation_time,
- delete_row_count=0,
+ delete_row_count=self._count_delete_rows(data),
file_source=0,
value_stats_cols=None if value_stats_enabled else [],
external_path=changelog_external_path,
diff --git a/paimon-python/pypaimon/write/writer/postpone_data_writer.py
b/paimon-python/pypaimon/write/writer/postpone_data_writer.py
index 7f53531a62..f28a5c09f3 100644
--- a/paimon-python/pypaimon/write/writer/postpone_data_writer.py
+++ b/paimon-python/pypaimon/write/writer/postpone_data_writer.py
@@ -79,21 +79,18 @@ class PostponeDataWriter(KeyValueDataWriter):
def _write_data_to_file(self, data):
sequence = data.column('_SEQUENCE_NUMBER')
- self._writing_stats = (
- sequence[0].as_py(), sequence[-1].as_py(),
- sum(kind in (1, 3) for kind in
data.column('_VALUE_KIND').to_pylist()))
+ self._writing_stats = (sequence[0].as_py(), sequence[-1].as_py())
try:
super()._write_data_to_file(data)
finally:
self._writing_stats = None
def _create_data_file_meta(self, **kwargs):
- minimum, maximum, deletes = self._writing_stats
+ assert self._writing_stats is not None
+ minimum, maximum = self._writing_stats
kwargs['min_sequence_number'] = minimum
kwargs['max_sequence_number'] = maximum
- meta = super()._create_data_file_meta(**kwargs)
- meta.delete_row_count = deletes
- return meta
+ return super()._create_data_file_meta(**kwargs)
def prepare_commit(self):
self._ensure_active()