blob: 0e791cb6f98d8fc57f99ca57b4f50997871ffeb0 [file]
# 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
@pytest.mark.parametrize('fmt', ['parquet', 'orc'])
@pytest.mark.parametrize('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)
@pytest.mark.parametrize('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)
@pytest.mark.parametrize('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}