blob: 1cbad4e1fc8472e3de63c615bc4c595e4d532f2f [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.
"""A writer uses one ordered write type after accepting its first row."""
import io
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.schema.data_types import PyarrowFieldParser
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
_ENGINES = [False, pytest.param(True, marks=[pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')])]
def _table(tmp_path, native, precomputed=False, unwritten_not_null=False, blob=False, unwritten_blob=False):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
fields = [pa.field('id', pa.int32()), pa.field('value', pa.int32())]
if unwritten_not_null:
fields.append(pa.field('omitted', pa.int32(), nullable=False))
if blob:
fields.append(pa.field('payload', pa.large_binary()))
if unwritten_blob:
fields.append(pa.field('unused_blob', pa.large_binary()))
schema = pa.schema(fields + [pa.field('pt', pa.string())])
options = {'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false'}
if precomputed:
options.update({'bucket': '2', 'bucket-key': 'id'})
if blob:
options.update({'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'})
catalog.create_table('default.factory', Schema.from_pyarrow_schema(
schema, partition_keys=['pt'], options=options), False)
return catalog.get_table('default.factory')
def _write(writer, table, rows, mode='arrow', columns=None):
columns = columns if columns is not None else table.field_names
table_schema = PyarrowFieldParser.from_paimon_schema(table.fields)
schema = pa.schema([table_schema.field(name) for name in columns])
data = pa.Table.from_pylist([{name: row[name] for name in columns} for row in rows], schema=schema)
if mode == 'row':
fields = [table.field_dict[name] for name in columns]
for row in rows:
writer.write_row(GenericRow([row[name] for name in columns], fields))
elif mode == 'precomputed':
for index in range(len(rows)):
writer.write_arrow(data.slice(index, 1), bucket=0)
else:
writer.write_arrow(data)
def _read(table):
builder = table.new_read_builder()
rows = builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
return sorted(rows, key=lambda row: (row['pt'], row['id'] or -1, row['value'] or -1))
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('mode', ['arrow', 'row', 'precomputed'])
@pytest.mark.parametrize('stage', ['write', 'checkpoint', 'idle'])
@pytest.mark.parametrize('initial_full', [False, True], ids=['subset', 'full'])
def test_write_type_is_fixed_after_first_write_even_after_idle_retirement(
tmp_path, native, mode, stage, initial_full):
table = _table(tmp_path, native, mode == 'precomputed')
builder = table.new_stream_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
columns = table.field_names if initial_full else ['id', 'pt']
try:
if not initial_full:
writer.with_write_type(columns)
_write(writer, table, [{'id': 1, 'value': 10, 'pt': 'old'}], mode)
if stage != 'write':
commit.commit(writer.prepare_commit(1), 1)
if stage == 'idle':
assert writer.prepare_commit(2) == []
assert writer.with_write_type(list(columns)) is writer
# Column order is part of the type, even when the field set is equal.
for different in (list(reversed(columns)), ['value', 'pt']):
with pytest.raises(RuntimeError, match='before any write'):
writer.with_write_type(different)
_write(writer, table, [{'id': 2, 'value': 20, 'pt': 'old'},
{'id': 3, 'value': 30, 'pt': 'new'}], mode)
messages = writer.prepare_commit(3)
assert all(file.write_cols == (None if initial_full else columns)
for message in messages for file in message.new_files)
commit.commit(messages, 3)
finally:
writer.close()
commit.close()
assert _read(table) == [
{'id': 3, 'value': 30 if initial_full else None, 'pt': 'new'},
{'id': 1, 'value': 10 if initial_full else None, 'pt': 'old'},
{'id': 2, 'value': 20 if initial_full else None, 'pt': 'old'},
]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'pandas'])
@pytest.mark.parametrize('kind', [None, '-D', '-U'], ids=['empty', 'delete', 'update-before'])
def test_empty_or_ignored_input_does_not_fix_write_type(tmp_path, native, input_, kind):
from pypaimon.tests.write.append_row_kind_test import _table as event_table, _write as write_event
table, schema = event_table(tmp_path, native, **{'ignore-delete': 'true'})
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.with_write_type(['id', 'pt'])
values = [] if kind is None else [dict(id=1, op=kind, pt='ignored')]
if kind is None and input_ == 'batch':
writer.write_arrow_batch(pa.RecordBatch.from_arrays(
[pa.array([], type=field.type) for field in schema], schema=schema))
else:
write_event(writer, table, schema, values, input_, True)
assert writer.with_write_type(['op', 'pt']) is writer
write_event(writer, table, schema, [dict(id=2, op='+I', pt='kept')], input_, True)
assert writer.with_write_type(['op', 'pt']) is writer
with pytest.raises(RuntimeError, match='before any write'):
writer.with_write_type(['id', 'pt'])
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
read = table.new_read_builder()
assert read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist() == [
dict(id=None, op='+I', pt='kept')]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('mode', ['arrow', 'row'])
def test_different_write_types_use_different_writers(tmp_path, native, mode):
table = _table(tmp_path, native)
builder = table.new_batch_write_builder()
left = builder.new_write().with_write_type(['id', 'pt'])
right = builder.new_write().with_write_type(['value', 'pt'])
commit = builder.new_commit()
try:
_write(left, table, [{'id': 1, 'value': 10, 'pt': 'old'}], mode)
_write(right, table, [{'id': 2, 'value': 20, 'pt': 'old'}], mode)
commit.commit(left.prepare_commit() + right.prepare_commit())
finally:
left.close()
right.close()
commit.close()
assert _read(table) == [
{'id': None, 'value': 20, 'pt': 'old'}, {'id': 1, 'value': None, 'pt': 'old'}]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
def test_incompatible_batch_is_rejected_before_any_partition_is_staged(tmp_path, native):
table = _table(tmp_path, native)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.with_write_type(['id', 'pt'])
_write(writer, table, [{'id': 1, 'value': 10, 'pt': 'old'}])
before = set(tmp_path.rglob('*.parquet'))
with pytest.raises(ValueError):
_write(writer, table, [{'value': 20, 'pt': 'new'}, {'value': 30, 'pt': 'old'}],
columns=['value', 'pt'])
assert set(tmp_path.rglob('*.parquet')) == before
_write(writer, table, [{'id': 2, 'value': 20, 'pt': 'old'},
{'id': 3, 'value': 30, 'pt': 'new'}])
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _read(table) == [
{'id': 3, 'value': None, 'pt': 'new'},
{'id': 1, 'value': None, 'pt': 'old'}, {'id': 2, 'value': None, 'pt': 'old'}]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
def test_reordered_named_rows_keep_the_selected_column_order(tmp_path, native):
table = _table(tmp_path, native, unwritten_not_null=True)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.with_write_type(['pt', 'id'])
_write(writer, table, [{'id': 1, 'value': 10, 'pt': 'old', 'omitted': None}],
'row', ['pt', 'value', 'id', 'omitted'])
_write(writer, table, [{'id': 2, 'value': 20, 'pt': 'old', 'omitted': None}],
'row', ['value', 'id', 'pt', 'omitted'])
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _read(table) == [
{'id': 1, 'value': None, 'pt': 'old', 'omitted': None},
{'id': 2, 'value': None, 'pt': 'old', 'omitted': None}]
def test_failed_bucket_creation_does_not_fix_write_type(tmp_path):
table = _table(tmp_path, False)
writer = table.new_batch_write_builder().new_write().with_write_type(['value', 'pt'])
try:
store = writer._execution.file_store_write
with patch.object(store, '_create_data_writer', side_effect=OSError('cannot create writer')):
with pytest.raises(OSError, match='cannot create writer'):
_write(writer, table, [{'id': 1, 'value': 10, 'pt': 'old'}])
assert store.data_writers == {}
writer.with_write_type(['id', 'pt'])
_write(writer, table, [{'id': 2, 'value': 20, 'pt': 'old'}])
messages = writer.prepare_commit()
assert [file.write_cols for message in messages for file in message.new_files] == [['id', 'pt']]
finally:
writer.close()
def test_requested_write_columns_are_distinct_from_optimized_physical_metadata(tmp_path):
table = _table(tmp_path, False)
writer = table.new_batch_write_builder().new_write().with_write_type(['pt', 'id'])
try:
store = writer._execution.file_store_write
optimized = Mock(write_cols=['id'])
with patch.object(store, '_create_data_writer', return_value=optimized):
_write(writer, table, [{'id': 1, 'value': 10, 'pt': 'old'}])
assert writer.with_write_type(['pt', 'id']) is writer
_write(writer, table, [{'id': 2, 'value': 20, 'pt': 'old'}])
assert [call[0][0].to_pydict() for call in optimized.write.call_args_list] == [
{'pt': ['old'], 'id': [1]}, {'pt': ['old'], 'id': [2]}]
store.data_writers.clear()
finally:
writer.close()
@pytest.mark.parametrize('backend', ['python', 'native-mock'])
@pytest.mark.parametrize('blob_input', ['none', 'omitted', 'selected'])
def test_first_row_encoding_failure_does_not_fix_write_type(tmp_path, backend, blob_input):
from pypaimon.execution.native.write.native_write import NativeTableWrite as NativeWrite
from pypaimon.table.row.blob import Blob
table = _table(tmp_path, False, blob=blob_input != 'none')
builder = table.new_batch_write_builder()
core = Mock(spec_set=['with_blob_uri_reader_factory', 'with_write_type', 'write_arrow', 'close'])
writer = builder.new_write() if backend == 'python' else NativeWrite(table, 'job', None, False, core)
payload = Mock(spec=Blob)
payload.new_input_stream.side_effect = AssertionError('unused Blob was opened')
payload.to_descriptor.side_effect = AssertionError('unused Blob was encoded')
columns = ['id', 'pt'] if blob_input != 'selected' else ['id', 'payload', 'pt']
values = {'id': 'invalid-int', 'pt': 'old'}
if blob_input != 'none':
values['payload'] = payload
try:
writer.with_write_type(columns)
with pytest.raises(pa.ArrowInvalid):
writer.write_row(GenericRow(list(values.values()), [table.field_dict[name] for name in values]))
assert not list(tmp_path.rglob('*.parquet')) and not list(tmp_path.rglob('*.blob'))
if backend == 'python':
assert writer._execution.file_store_write.data_writers == {}
assert not writer._execution.file_store_write._ever_written
else:
core.write_arrow.assert_not_called()
assert not writer._written
assert writer.with_write_type(['value', 'pt']) is writer
values['id'] = 1
values['value'] = 20
writer.write_row(GenericRow(list(values.values()), [table.field_dict[name] for name in values]))
if backend == 'python':
commit = builder.new_commit()
try:
commit.commit(writer.prepare_commit())
finally:
commit.close()
expected = {'id': None, 'value': 20, 'pt': 'old'}
if blob_input != 'none':
expected['payload'] = None
assert _read(table) == [expected]
else:
core.write_arrow.assert_called_once()
assert core.write_arrow.call_args[0][0].to_pydict() == {'value': [20], 'pt': ['old']}
payload.new_input_stream.assert_not_called()
payload.to_descriptor.assert_not_called()
finally:
writer.close()
def test_blob_row_encoding_retry_consumes_the_selected_live_blob_once(tmp_path):
from pypaimon.table.row.blob import Blob
class InputBlob(Blob):
opened = 0
def to_data(self):
raise AssertionError('live Blob was materialized')
def to_descriptor(self):
raise AssertionError('live Blob was encoded')
def new_input_stream(self):
self.opened += 1
return io.BytesIO(b'payload')
table = _table(tmp_path, False, blob=True)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
payload = InputBlob()
try:
writer.with_write_type(['id', 'payload', 'pt'])
with pytest.raises(pa.ArrowInvalid):
writer.write_row(GenericRow(['invalid-int', payload, 'old'],
[table.field_dict[name] for name in ['id', 'payload', 'pt']]))
assert payload.opened == 0
writer.with_write_type(['value', 'payload', 'pt'])
writer.write_row(GenericRow([20, payload, 'old'],
[table.field_dict[name] for name in ['value', 'payload', 'pt']]))
assert payload.opened == 1
commit.commit(writer.prepare_commit())
assert payload.opened == 1
finally:
writer.close()
commit.close()
assert _read(table) == [dict(id=None, value=20, payload=b'payload', pt='old')]
def test_failure_after_row_encoding_keeps_the_created_write_type_fixed(tmp_path):
from pypaimon.execution.python.write.writer.append_only_data_writer import AppendOnlyDataWriter
table = _table(tmp_path, False)
writer = table.new_batch_write_builder().new_write().with_write_type(['id', 'pt'])
failure = OSError('writer failed after row encoding')
try:
with patch.object(AppendOnlyDataWriter, 'write', side_effect=failure):
with pytest.raises(OSError) as caught:
writer.write_row(GenericRow([1, 'old'], [table.field_dict[name] for name in ['id', 'pt']]))
assert caught.value is failure
assert writer.with_write_type(['id', 'pt']) is writer
with pytest.raises(RuntimeError, match='before any write'):
writer.with_write_type(['value', 'pt'])
finally:
writer.close()
@pytest.mark.parametrize('backend', ['python', 'native-mock'])
@pytest.mark.parametrize('inline', ['descriptor', 'view'])
def test_inline_blob_conversion_failure_does_not_fix_write_type(tmp_path, backend, inline):
from pypaimon.execution.native.write.native_write import NativeTableWrite as NativeWrite
from pypaimon.table.row.blob import Blob, BlobView
table = _table(tmp_path, False, blob=True).copy({'blob-%s-field' % inline: 'payload'})
builder = table.new_batch_write_builder()
core = Mock(spec_set=['with_blob_uri_reader_factory', 'with_write_type', 'write_arrow', 'close'])
writer = builder.new_write() if backend == 'python' else NativeWrite(table, 'job', None, False, core)
value = Blob.from_data(b'not a descriptor')
if inline == 'view':
value = Mock(spec=BlobView)
value.view_struct.serialize.side_effect = ValueError('cannot serialize view')
value.new_input_stream.side_effect = AssertionError('view stream was opened')
try:
writer.with_write_type(['payload', 'pt'])
with pytest.raises(ValueError):
writer.write_row(GenericRow([value, 'old'], [table.field_dict[name] for name in ['payload', 'pt']]))
assert not list(tmp_path.rglob('*.parquet')) and not list(tmp_path.rglob('*.blob'))
if backend == 'python':
assert writer._execution.file_store_write.data_writers == {}
assert not writer._execution.file_store_write._ever_written
else:
core.write_arrow.assert_not_called()
assert not writer._written
assert writer.with_write_type(['value', 'pt']) is writer
writer.write_row(GenericRow([20, 'old'], [table.field_dict[name] for name in ['value', 'pt']]))
if backend == 'python':
commit = builder.new_commit()
try:
commit.commit(writer.prepare_commit())
finally:
commit.close()
assert _read(table) == [dict(id=None, value=20, payload=None, pt='old')]
else:
assert core.write_arrow.call_args[0][0].to_pydict() == {'value': [20], 'pt': ['old']}
finally:
writer.close()
@pytest.mark.parametrize('inline', ['descriptor', 'view'])
def test_inline_blob_wire_validation_failure_keeps_write_type_fixed(tmp_path, inline):
table = _table(tmp_path, False, blob=True).copy({'blob-%s-field' % inline: 'payload'})
writer = table.new_batch_write_builder().new_write().with_write_type(['payload', 'pt'])
try:
with pytest.raises(ValueError, match='blob-%s-field' % inline):
writer.write_row(GenericRow([b'invalid wire bytes', 'old'],
[table.field_dict[name] for name in ['payload', 'pt']]))
assert writer._execution.file_store_write._ever_written
assert writer.with_write_type(['payload', 'pt']) is writer
with pytest.raises(RuntimeError, match='before any write'):
writer.with_write_type(['value', 'pt'])
finally:
writer.close()
@pytest.mark.parametrize('inline', ['descriptor', 'view'])
def test_inline_blob_encoding_is_reused_and_unselected_blob_is_untouched(tmp_path, inline):
import pyarrow.parquet as pq
from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobView, BlobViewStruct
table = _table(tmp_path, False, blob=True, unwritten_blob=True).copy({'blob-%s-field' % inline: 'payload'})
if inline == 'descriptor':
descriptor = BlobDescriptor('file:///unused-source', 0, 7)
encoded = descriptor.serialize()
value = Mock(spec=Blob)
value.to_descriptor.side_effect = [descriptor, AssertionError('descriptor encoded twice')]
else:
encoded = BlobViewStruct('default.upstream', 1, 2).serialize()
value = Mock(spec=BlobView)
value.view_struct.serialize.side_effect = [encoded, AssertionError('view encoded twice')]
value.new_input_stream.side_effect = AssertionError('inline Blob stream was opened')
unused = Mock(spec=Blob)
unused.to_descriptor.side_effect = AssertionError('unselected Blob was encoded')
unused.new_input_stream.side_effect = AssertionError('unselected Blob stream was opened')
writer = table.new_batch_write_builder().new_write().with_write_type(['payload', 'pt'])
try:
names = ['payload', 'unused_blob', 'pt']
writer.write_row(GenericRow([value, unused, 'old'], [table.field_dict[name] for name in names]))
messages = writer.prepare_commit()
paths = [file.file_path for message in messages for file in message.new_files]
assert len(paths) == 1
assert pq.ParquetFile(paths[0]).read(columns=['payload']).column('payload').to_pylist() == [encoded]
if inline == 'descriptor':
value.to_descriptor.assert_called_once()
else:
value.view_struct.serialize.assert_called_once()
value.new_input_stream.assert_not_called()
unused.to_descriptor.assert_not_called()
unused.new_input_stream.assert_not_called()
finally:
writer.close()