blob: 403c1b397ed68608cf23ae0eb5b6cba4b560fefc [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.
"""End-to-end coverage of the optional native batch row-ID update bridge."""
import json
from unittest.mock import patch
import pyarrow as pa
import pyarrow.compute as pc
import pytest
from pypaimon import Schema
pytestmark = pytest.mark.native_plan
def _table(catalog, rows=None, partitions=False):
schema = pa.schema([('p', pa.string()), ('id', pa.int32()), ('value', pa.int32())])
catalog.create_table('default.shards', Schema.from_pyarrow_schema(
schema, partition_keys=['p'] if partitions else [], options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'deletion-vectors.enabled': 'true', 'write.native.enabled': 'true',
}), False)
table = catalog.get_table('default.shards')
if rows:
_append(table, rows, schema)
return table, schema
def _append(table, rows, schema):
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
builder.new_commit().commit(writer.prepare_commit())
finally:
writer.close()
def _updater(table, index=0, count=1, projection=('id',), columns=('value',), stream=False):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update()
if projection is not None:
update.with_read_projection(projection if isinstance(projection, dict) else list(projection))
update.with_update_type(list(columns))
with patch('pypaimon.write.table_update.ShardTableUpdator',
side_effect=AssertionError('Python shard mapping was used')):
return update.new_shard_updator(index, count)
def _rows(table):
builder = table.new_read_builder()
return builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pydict()
@pytest.mark.parametrize('operation', ['update', 'delete', 'shard'])
def test_direct_public_mutation_constructors_select_native(native_rest_catalog, operation):
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.table_delete import TableDeleteByRowId
from pypaimon.write.table_update import ShardTableUpdator
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
table, _ = _table(native_rest_catalog, [
dict(p='a', id=1, value=10), dict(p='a', id=2, value=20)])
builder = table.new_batch_write_builder()
if operation == 'update':
with patch('pypaimon.execution.python.write.table_update_by_row_id.PythonTableUpdateByRowId.initialize',
side_effect=AssertionError('Direct update initialized Python')):
update = TableUpdateByRowId(table, builder.commit_user, BATCH_COMMIT_IDENTIFIER)
messages = update.update_columns(pa.table({'_ROW_ID': [0], 'value': [11]}), ['value'])
assert len(update.commit_messages) == len(messages)
expected = dict(p=['a', 'a'], id=[1, 2], value=[11, 20])
elif operation == 'delete':
with patch('pypaimon.execution.python.write.table_delete.PythonTableDeleteByRowId.initialize',
side_effect=AssertionError('Direct delete initialized Python')):
messages = TableDeleteByRowId(table).delete([0, 0])
expected = dict(p=['a'], id=[2], value=[20])
else:
with patch('pypaimon.execution.python.write.table_update.ShardTableUpdator.initialize',
side_effect=AssertionError('Direct shard initialized Python')):
update = ShardTableUpdator(table, ['id'], ['value'], builder.commit_user, 0, 1)
try:
with update.arrow_reader() as reader:
for batch in reader:
update.update_by_arrow_batch(pa.record_batch([
pc.multiply(batch['id'], 100)], names=['value']))
messages = update.prepare_commit()
finally:
update.close()
expected = dict(p=['a', 'a'], id=[1, 2], value=[100, 200])
assert messages and all(message.check_from_snapshot == 1 for message in messages)
builder.new_commit().commit(messages)
assert _rows(table) == expected
@pytest.mark.parametrize('stream', [False, True])
def test_native_shard_updates_use_the_captured_reader_order(native_rest_catalog, stream):
table, schema = _table(native_rest_catalog, [
dict(p='a', id=1, value=10), dict(p='a', id=2, value=20)])
updater = _updater(table, stream=stream)
_append(table, [dict(p='a', id=3, value=30)], schema)
reader = updater.arrow_reader()
for batch in reader:
assert batch.schema.names == ['id']
updater.update_by_arrow_batch(pa.record_batch([pc.multiply(batch['id'], 100)], names=['value']))
messages = updater.prepare_commit()
assert all(message.check_from_snapshot == 1 for message in messages)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
builder.new_commit().commit(messages, 27) if stream else builder.new_commit().commit(messages)
updater.close()
assert _rows(table) == dict(p=['a', 'a', 'a'], id=[1, 2, 3], value=[100, 200, 30])
@pytest.mark.parametrize('partitions', [False, True])
@pytest.mark.parametrize('count', [1, 3, 7])
def test_native_shards_are_balanced_by_file_group(native_rest_catalog, partitions, count):
table, schema = _table(native_rest_catalog, partitions=partitions)
for i in range(5):
_append(table, [dict(p=str(i % 2), id=i, value=i * 10)], schema)
snapshot_id = table.snapshot_manager().get_latest_snapshot().id
updaters = [_updater(table, i, count, projection=('_ROW_ID', 'id')) for i in range(count)]
sizes = []
for updater in updaters:
with updater.arrow_reader() as reader:
batches = list(reader)
sizes.append(sum(batch.num_rows for batch in batches))
if batches:
ids = pa.Table.from_batches(batches)['id']
# One input table may span physical files and partitions.
updater.update_by_arrow_batch(pa.record_batch([pc.multiply(ids, 100).combine_chunks()], names=['value']))
messages = updater.prepare_commit()
assert all(message.check_from_snapshot == snapshot_id for message in messages)
for message in messages:
assert message.bucket == 0
assert all(file.first_row_id is not None and file.write_cols == ['value'] for file in message.new_files)
table.new_batch_write_builder().new_commit().commit(messages)
updater.close()
base, remainder = divmod(5, count)
assert sizes == [base + (i < remainder) for i in range(count)]
assert _rows(table)['value'] == [0, 100, 200, 300, 400]
# Replacements and base files overlap, so they must still be one file group.
with _updater(table, 0, 7).arrow_reader() as reader:
assert sum(batch.num_rows for batch in reader) == 1
def test_native_shard_deleted_rows_do_not_shift_updates(native_rest_catalog):
table, _ = _table(native_rest_catalog, [dict(p='a', id=i, value=i * 10) for i in range(1, 5)])
builder = table.new_batch_write_builder()
builder.new_commit().commit(builder.new_update().delete_by_row_id([1, 3]))
updater = _updater(table, projection=('id', '_ROW_ID'))
with updater.arrow_reader() as reader:
data = pa.Table.from_batches(list(reader))
assert data.to_pydict() == {'id': [1, 3], '_ROW_ID': [0, 2]}
updater.update_by_arrow_batch(pa.record_batch([pa.array([111, 333], type=pa.int32())], names=['value']))
messages = updater.prepare_commit()
assert all(file.row_count == 4 and file.first_row_id == 0 for m in messages for file in m.new_files)
table.new_batch_write_builder().new_commit().commit(messages)
assert _rows(table) == dict(p=['a', 'a'], id=[1, 3], value=[111, 333])
@pytest.mark.parametrize('bad', ['wrong_column', 'wrong_type', 'excess_rows'])
def test_native_shard_invalid_batch_does_not_consume_row_ids(native_rest_catalog, bad):
table, _ = _table(native_rest_catalog, [dict(p='a', id=i, value=i * 10) for i in range(1, 4)])
updater = _updater(table)
with updater.arrow_reader() as reader:
assert sum(batch.num_rows for batch in reader) == 3
if bad == 'wrong_column':
data = pa.record_batch([pa.array([99], type=pa.int32())], names=['missing'])
elif bad == 'wrong_type':
data = pa.record_batch([pa.array(['invalid'])], names=['value'])
else:
data = pa.record_batch([pa.array([99] * 4, type=pa.int32())], names=['value'])
with pytest.raises(ValueError):
updater.update_by_arrow_batch(data)
updater.update_by_arrow_batch(pa.record_batch([pa.array([100], type=pa.int32())], names=['value']))
with pytest.raises(ValueError, match='same number of rows'):
updater.prepare_commit()
assert table.snapshot_manager().get_latest_snapshot().id == 1
updater.update_by_arrow_batch(pa.record_batch([pa.array([200, 300], type=pa.int32())], names=['value']))
messages = updater.prepare_commit()
repeated = updater.prepare_commit()
assert [f.file_name for m in messages for f in m.new_files] == [f.file_name for m in repeated for f in m.new_files]
updater.close()
table.new_batch_write_builder().new_commit().commit(messages)
assert _rows(table)['value'] == [100, 200, 300]
with pytest.raises(ValueError):
updater.update_by_arrow_batch(data)
def test_native_shard_closed_reader_cannot_prepare_an_incomplete_update(native_rest_catalog):
table, schema = _table(native_rest_catalog, [dict(p='a', id=1, value=10)])
_append(table, [dict(p='a', id=2, value=20)], schema)
updater = _updater(table)
reader = updater.arrow_reader()
batch = reader.read_next_batch()
updater.update_by_arrow_batch(pa.record_batch([pa.array([99] * batch.num_rows, type=pa.int32())], names=['value']))
reader.close()
with pytest.raises(ValueError, match='same number of rows'):
updater.prepare_commit()
with pytest.raises(ValueError, match='only be opened once'):
updater.arrow_reader()
updater.close()
assert _rows(table)['value'] == [10, 20]
def test_native_shard_no_row_count_rolling_and_full_column_update(native_rest_catalog):
table, _ = _table(native_rest_catalog, [dict(p='a', id=i, value=i * 10) for i in range(5)])
table = table.copy({'target-file-row-num': '1'})
# All-column updates follow the same contract as a column subset.
updater = _updater(table, projection=('id',), columns=('p', 'id', 'value'))
with updater.arrow_reader() as reader:
batches = list(reader)
for batch in batches:
ids = batch['id'].to_pylist()
updater.update_by_arrow_batch(pa.record_batch([
pa.array([i + 10 for i in ids], type=pa.int32()),
pa.array(['new'] * len(ids)),
pa.array([i + 100 for i in ids], type=pa.int32()),
], names=['id', 'p', 'value']))
messages = updater.prepare_commit()
assert len([file for m in messages for file in m.new_files]) == 1
table.new_batch_write_builder().new_commit().commit(messages)
assert _rows(table) == dict(p=['new'] * 5, id=list(range(10, 15)), value=list(range(100, 105)))
def test_native_empty_shard_and_configuration_validation(native_rest_catalog):
table, _ = _table(native_rest_catalog)
updater = _updater(table, 2, 4)
with updater.arrow_reader() as reader:
assert list(reader) == []
assert updater.prepare_commit() == []
for index, count in [(-1, 1), (0, 0), (2, 2)]:
with pytest.raises(ValueError, match='Shard index'):
table.new_batch_write_builder().new_update().new_shard_updator(index, count)
with pytest.raises(ValueError, match='not be empty'):
_updater(table, columns=())
def test_native_shard_rejects_changing_partition_values(native_rest_catalog):
table, _ = _table(native_rest_catalog, [dict(p='a', id=1, value=10)], partitions=True)
updater = _updater(table, columns=('p',))
try:
with updater.arrow_reader() as reader:
assert sum(batch.num_rows for batch in reader) == 1
# Validation may run when a file group flushes or when preparing.
with pytest.raises(ValueError, match='Cannot change partition column'):
updater.update_by_arrow_batch(pa.record_batch([pa.array(['changed'])], names=['p']))
updater.prepare_commit()
finally:
updater.close()
assert table.snapshot_manager().get_latest_snapshot().id == 1
assert _rows(table) == dict(p=['a'], id=[1], value=[10])
@pytest.mark.parametrize('data_type, original, replacement', [
(pa.list_(pa.int32()), [[1, None], None], [[3, 4], []]),
(pa.map_(pa.string(), pa.int32()), [[('a', 1)], None], [[('b', None)], []]),
(pa.struct([('a', pa.int32()), ('b', pa.string())]),
[dict(a=1, b='before'), None], [dict(a=None, b='after'), dict(a=2, b=None)]),
])
def test_native_shard_updates_existing_complex_column_types(native_rest_catalog, data_type, original, replacement):
schema = pa.schema([('id', pa.int32()), ('value', data_type)])
native_rest_catalog.create_table('default.complex_shards', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': 'true',
}), False)
table = native_rest_catalog.get_table('default.complex_shards')
builder = table.new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow(pa.table({'id': [1, 2], 'value': original}, schema=schema))
builder.new_commit().commit(writer.prepare_commit())
writer.close()
updater = _updater(table)
with updater.arrow_reader() as reader:
assert sum(batch.num_rows for batch in reader) == 2
updater.update_by_arrow_batch(pa.record_batch([pa.array(replacement, type=data_type)], names=['value']))
table.new_batch_write_builder().new_commit().commit(updater.prepare_commit())
assert _rows(table) == dict(id=[1, 2], value=replacement)
@pytest.mark.parametrize('projection, expected_names', [(None, ['p', 'id', 'value']), ((), [])])
def test_native_shard_default_and_empty_projection_preserve_rows(native_rest_catalog, projection, expected_names):
table, _ = _table(native_rest_catalog, [dict(p='a', id=i, value=i * 10) for i in range(1, 4)])
updater = _updater(table, projection=projection)
with updater.arrow_reader() as reader:
count = 0
for batch in reader:
assert batch.schema.names == expected_names
count += batch.num_rows
updater.update_by_arrow_batch(pa.record_batch([
pa.array([99] * batch.num_rows, type=pa.int32())], names=['value']))
assert count == 3
table.new_batch_write_builder().new_commit().commit(updater.prepare_commit())
assert _rows(table) == dict(p=['a'] * 3, id=[1, 2, 3], value=[99] * 3)
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('projection, expected', [
(['payload.b'], {'payload_b': [3, None, 4]}),
(["attributes['key']"], {'attributes_key': [5, None, None]}),
({'source': 'id'}, {'source': [1, 2, 3]}),
])
def test_native_shard_read_type_preserves_nested_map_and_alias_outputs(
native_rest_catalog, stream, projection, expected):
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())),
])
native_rest_catalog.create_table('default.projected_shards', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true', 'write.native.enabled': 'true',
}), False)
table = native_rest_catalog.get_table('default.projected_shards')
_append(table, [
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=[]),
], schema)
updater = _updater(table, projection=projection, stream=stream)
with updater.arrow_reader() as reader:
data = pa.Table.from_batches(list(reader), schema=reader.schema)
assert data.to_pydict() == expected
replacement = data.column(0).combine_chunks()
updater.update_by_arrow_batch(pa.record_batch([replacement], names=['value']))
table.new_batch_write_builder().new_commit().commit(updater.prepare_commit())
result = _rows(table)
assert result['id'] == [1, 2, 3]
assert result['value'] == replacement.to_pylist()
assert result['payload'] == [dict(a=100, b=3), None, dict(a=200, b=4)]
assert result['attributes'] == [[('key', 5), ('other', 9)], None, []]
def test_native_shard_rejects_malformed_read_type_before_starting(native_rest_catalog):
from pypaimon.execution.native.write.native_commit import create_native_write_table
table, _ = _table(native_rest_catalog, [dict(p='a', id=1, value=10)])
native = create_native_write_table(table)
update = native.new_batch_write_builder().new_update().with_update_type(['value'])
for read_type in ('{', '{"type": "INT"}'):
with pytest.raises(ValueError, match='invalid Paimon read type'):
update.new_shard_updator(0, 1, read_type=read_type)
for fields in (
[{'id': 2147483642, 'name': '_ROW_ID', 'type': 'INT'}],
[{'id': 0, 'name': '_ROW_ID', 'type': 'BIGINT'}],
[{'id': 2147483642, 'name': '_ROW_ID', 'type': 'BIGINT'}] * 2):
with pytest.raises(ValueError, match='canonical BIGINT _ROW_ID'):
update.new_shard_updator(0, 1, read_type=json.dumps({'type': 'ROW', 'fields': fields}))
assert _rows(table)['value'] == [10]
@pytest.mark.parametrize('stream', [False, True])
def test_native_shard_read_type_preserves_variant_extraction(native_rest_catalog, stream):
from pypaimon.data.generic_variant import GenericVariant
payloads = [dict(v=1.5), None, dict(v=3.0)]
variants = GenericVariant.to_arrow_array([
GenericVariant.from_python(value) if value is not None else None for value in payloads])
schema = pa.schema([('id', pa.int32()), ('payload', variants.type), ('value', pa.float32())])
native_rest_catalog.create_table('default.variant_shards', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true', 'write.native.enabled': 'true',
}), False)
table = native_rest_catalog.get_table('default.variant_shards')
data = pa.table({'id': [1, 2, 3], 'payload': variants, 'value': [0.0] * 3}, schema=schema)
_append(table, data.to_pylist(), schema)
updater = _updater(table, projection={
'source': 'id', 'picked': "try_variant_get(payload, '$.v', 'float')"}, stream=stream)
with updater.arrow_reader() as reader:
data = pa.Table.from_batches(list(reader), schema=reader.schema)
assert data.to_pydict() == dict(source=[1, 2, 3], picked=[1.5, None, 3.0])
assert data.schema.field('picked').type == pa.float32()
updater.update_by_arrow_batch(pa.record_batch([data['picked'].combine_chunks()], names=['value']))
table.new_batch_write_builder().new_commit().commit(updater.prepare_commit())
result = _rows(table)
assert result['value'] == [1.5, None, 3.0]
assert [GenericVariant.from_arrow_struct(value).to_python() if value is not None else None
for value in result['payload']] == payloads