blob: bf87b37138cb40e969e4d944a4fdc5afa86cd69b [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.
"""Python shard updates follow the existing Rust row and lifecycle contract."""
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.schema.data_types import AtomicType, DataField, PyarrowFieldParser
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
@pytest.fixture
def python_shard_table(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
catalog.create_table('default.shards', Schema.from_pyarrow_schema(schema, options={
'bucket': '-1', 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'deletion-vectors.enabled': 'true', 'write.native.enabled': 'false',
'commit.native.enabled': 'false', 'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
}), False)
return catalog.get_table('default.shards')
def _append(table, rows):
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
def _commit(table, messages, stream=False):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
commit = builder.new_commit()
try:
commit.commit(messages, 37) if stream else commit.commit(messages)
finally:
commit.close()
def _updater(table, stream=False, projection=('id',), columns=('value',), shard=0, count=1):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update()
update.with_read_projection(None if projection is None else projection)
if columns is not None:
update.with_update_type(list(columns))
return update.new_shard_updator(shard, count)
def _values(values):
return pa.record_batch([pa.array(values, type=pa.int32())], names=['value'])
def _rows(table):
read = table.new_read_builder()
return read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id').to_pylist()
@pytest.mark.parametrize('stream', [False, True])
def test_deleted_rows_do_not_consume_shard_update_values(python_shard_table, stream):
table = python_shard_table
_append(table, [dict(id=i, value=i * 10) for i in range(1, 5)])
builder = table.new_batch_write_builder()
_commit(table, builder.new_update().delete_by_row_id([1, 3]))
shard = _updater(table, stream, projection=['id', '_ROW_ID'])
with shard.arrow_reader() as reader:
visible = pa.Table.from_batches(list(reader))
assert visible.to_pydict() == dict(id=[1, 3], _ROW_ID=[0, 2])
shard.update_by_arrow_batch(_values([111]))
shard.update_by_arrow_batch(_values([333]))
messages = shard.prepare_commit()
assert len(messages) == 1
assert all(file.row_count == 4 and file.first_row_id == 0 for message in messages for file in message.new_files)
shard.close()
shard.close()
# Closing after preparation must preserve the files the caller may publish.
_commit(table, messages, stream)
assert _rows(table) == [dict(id=1, value=111), dict(id=3, value=333)]
if stream:
assert table.snapshot_manager().get_latest_snapshot().commit_identifier == 37
@pytest.mark.parametrize('bad', ['column', 'type', 'excess'])
def test_invalid_input_and_early_prepare_do_not_consume_pending_rows(python_shard_table, bad):
table = python_shard_table
_append(table, [dict(id=i, value=i * 10) for i in range(1, 4)])
shard = _updater(table)
with shard.arrow_reader() as reader:
assert sum(batch.num_rows for batch in reader) == 3
data = (pa.record_batch([pa.array([1])], names=['missing']) if bad == 'column' else
pa.record_batch([pa.array(['invalid'])], names=['value']) if bad == 'type' else _values([1, 2, 3, 4]))
with pytest.raises((ValueError, TypeError)):
shard.update_by_arrow_batch(data)
shard.update_by_arrow_batch(_values([100]))
with pytest.raises(ValueError, match='same number of rows'):
shard.prepare_commit()
shard.update_by_arrow_batch(_values([200, 300]))
messages = shard.prepare_commit()
repeated = shard.prepare_commit()
assert [file.file_name for msg in messages for file in msg.new_files] == [
file.file_name for msg in repeated for file in msg.new_files]
with pytest.raises(ValueError, match='already prepared'):
shard.update_by_arrow_batch(_values([9]))
shard.close()
_commit(table, messages)
assert _rows(table) == [dict(id=i, value=i * 100) for i in range(1, 4)]
def test_reader_must_finish_and_cannot_be_opened_twice(python_shard_table):
table = python_shard_table
for i in range(1, 3):
_append(table, [dict(id=i, value=i * 10)])
shard = _updater(table)
with pytest.raises(ValueError, match='more rows than have been read'):
shard.update_by_arrow_batch(_values([99]))
reader = shard.arrow_reader()
batch = reader.read_next_batch()
shard.update_by_arrow_batch(_values([99] * batch.num_rows))
reader.close()
with pytest.raises(ValueError, match='same number of rows'):
shard.prepare_commit()
with pytest.raises(ValueError, match='only be opened once'):
shard.arrow_reader()
shard.close()
assert _rows(table) == [dict(id=1, value=10), dict(id=2, value=20)]
@pytest.mark.parametrize('start_reader', [False, True])
def test_close_cancels_an_unprepared_shard(python_shard_table, start_reader):
table = python_shard_table
_append(table, [dict(id=1, value=10)])
shard = _updater(table)
if start_reader:
shard.arrow_reader().read_next_batch()
shard.close()
shard.close()
for operation in (shard.arrow_reader, shard.prepare_commit, lambda: shard.update_by_arrow_batch(_values([99]))):
with pytest.raises(ValueError, match='closed'):
operation()
assert _rows(table) == [dict(id=1, value=10)]
@pytest.mark.parametrize('close_failure', [False, True])
def test_read_failure_poisoning_prevents_a_partial_commit(python_shard_table, monkeypatch, close_failure):
from pypaimon.read.table_read import TableRead
table = python_shard_table
_append(table, [dict(id=1, value=10)])
shard = _updater(table)
class FailedSource:
closed = False
def __iter__(self):
return self
def __next__(self):
raise RuntimeError('failed to read shard')
def close(self):
self.closed = True
if close_failure:
raise OSError('failed to close shard source')
source = FailedSource()
monkeypatch.setattr(TableRead, '_to_managed_arrow_batch_reader', lambda *args, **kwargs: source)
reader = shard.arrow_reader()
with pytest.raises(RuntimeError, match='failed to read shard'):
reader.read_next_batch()
assert source.closed
with pytest.raises(ValueError, match='failed'):
shard.prepare_commit()
shard.close()
def test_reader_close_releases_source_before_reading_any_batch(python_shard_table, monkeypatch):
from pypaimon.read.table_read import TableRead
table = python_shard_table
_append(table, [dict(id=1, value=10)])
shard = _updater(table)
class UnstartedSource:
close_count = 0
def close(self):
self.close_count += 1
assert self.close_count == 1, 'source was closed twice'
source = UnstartedSource()
monkeypatch.setattr(TableRead, '_to_managed_arrow_batch_reader', lambda *args, **kwargs: source)
reader = shard.arrow_reader()
reader.close()
assert source.close_count == 1
with pytest.raises(ValueError, match='same number of rows'):
shard.prepare_commit()
reader.close()
shard.close()
shard.close()
assert source.close_count == 1
@pytest.mark.parametrize('all_deleted', [False, True])
def test_empty_and_fully_deleted_shards_have_no_updates(python_shard_table, all_deleted):
table = python_shard_table
_append(table, [dict(id=1, value=10)])
if all_deleted:
_commit(table, table.new_batch_write_builder().new_update().delete_by_row_id([0]))
shard = _updater(table, shard=0 if all_deleted else 1, count=1 if all_deleted else 2)
with shard.arrow_reader() as reader:
assert list(reader) == []
assert shard.prepare_commit() == []
shard.close()
@pytest.mark.parametrize('projection, expected', [
(None, ['id', 'value']), ([], []), ({'source': 'id'}, ['source']),
])
def test_projection_preserves_rows_without_exposing_internal_row_ids(python_shard_table, projection, expected):
table = python_shard_table
_append(table, [dict(id=i, value=i * 10) for i in range(1, 4)])
shard = _updater(table, projection=projection)
with shard.arrow_reader() as reader:
count = 0
for batch in reader:
assert batch.schema.names == expected
count += batch.num_rows
shard.update_by_arrow_batch(_values([99] * batch.num_rows))
assert count == 3
_commit(table, shard.prepare_commit())
shard.close()
assert [row['value'] for row in _rows(table)] == [99, 99, 99]
def test_captured_shard_preserves_file_groups_and_ignores_new_rows(python_shard_table):
table = python_shard_table
for i in range(1, 3):
_append(table, [dict(id=i, value=i * 10)])
shard = _updater(table.copy({'target-file-row-num': '1'}))
_append(table, [dict(id=3, value=30)])
with shard.arrow_reader() as reader:
assert pa.Table.from_batches(list(reader)).to_pydict() == {'id': [1, 2]}
shard.update_by_arrow_batch(_values([100, 200]))
messages = shard.prepare_commit()
assert len(messages) == 2
assert all(message.check_from_snapshot == 2 for message in messages)
_commit(table, messages)
shard.close()
assert _rows(table) == [dict(id=1, value=100), dict(id=2, value=200), dict(id=3, value=30)]
def test_map_projection_keeps_internal_row_ids_separate(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('default', True)
schema = pa.schema([
('id', pa.int32()), ('value', pa.int32()),
('payload', pa.struct([('a', pa.int32()), ('b', pa.int32())])),
('attributes', pa.map_(pa.string(), pa.int32())),
])
catalog.create_table('default.nested', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
}), False)
table = catalog.get_table('default.nested')
original = [
dict(id=1, value=10, payload=dict(a=100, b=3), attributes=[('key', 5), ('other', 9)]),
dict(id=2, value=20, payload=None, attributes=None),
dict(id=3, value=30, payload=dict(a=200, b=4), attributes=[]),
]
_append(table, original)
shard = _updater(table, projection=["attributes['key']"])
with shard.arrow_reader() as reader:
data = pa.Table.from_batches(list(reader), schema=reader.schema)
assert data.to_pydict() == {'attributes_key': [5, None, None]}
shard.update_by_arrow_batch(pa.record_batch([data.column(0).combine_chunks()], names=['value']))
_commit(table, shard.prepare_commit())
shard.close()
expected_rows = [dict(row, value=value) for row, value in zip(original, data.column(0).to_pylist())]
assert _rows(table) == expected_rows
def test_dedicated_blob_updates_keep_deleted_physical_positions(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('default', True)
catalog.create_table('default.blobs', Schema([
DataField(0, 'id', AtomicType('INT')), DataField(1, 'payload', AtomicType('BLOB')),
], options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'deletion-vectors.enabled': 'true', 'write.native.enabled': 'false',
'commit.native.enabled': 'false', 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false', 'blob.target-file-size': '8 B'}), False)
table = catalog.get_table('default.blobs')
_append(table, [dict(id=i, payload=b'old-' + bytes([i])) for i in range(3)])
_commit(table, table.new_batch_write_builder().new_update().delete_by_row_id([1]))
shard = _updater(table, columns=('payload',))
with shard.arrow_reader() as reader:
assert pa.Table.from_batches(list(reader)).to_pydict() == {'id': [0, 2]}
shard.update_by_arrow_batch(pa.record_batch([
pa.array([b'first', b'last'], type=pa.large_binary())], names=['payload']))
messages = shard.prepare_commit()
assert any(file.file_name.endswith('.blob') for message in messages for file in message.new_files)
shard.close()
_commit(table, messages)
assert _rows(table) == [dict(id=0, payload=b'first'), dict(id=2, payload=b'last')]