blob: 5c1624a31ea2c71e66153716b5a4467283d5c4bc [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.
"""Existing row Blob inputs must retain the core writer and source lifetime."""
import io
from decimal import Decimal
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.schema.data_types import AtomicType, ArrayType, DataField, MapType, PyarrowFieldParser
from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobViewStruct
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.write_dispatch import NativeTableWrite
pytestmark = pytest.mark.native_plan
class StreamBlob(Blob):
def __init__(self, payload, error=None):
self.payload = payload
self.error = error
self.streams = []
self.opened = 0
def to_data(self):
raise AssertionError('Blob payload was materialized in Python')
def to_descriptor(self):
raise AssertionError('Custom Blob must use its input stream')
def new_input_stream(self):
self.opened += 1
if self.error is not None:
raise self.error
class Stream(io.BytesIO):
def __init__(self, payload):
super().__init__(payload)
self.read_sizes = []
def read(self, size=-1):
assert 0 < size <= 3, 'Core must use its configured copy buffer'
self.read_sizes.append(size)
return super().read(size)
stream = Stream(self.payload)
self.streams.append(stream)
return stream
def _type(kind):
blob = AtomicType('BLOB')
if kind == 'array':
return ArrayType(True, blob)
if kind == 'map':
return MapType(True, AtomicType('STRING'), blob)
return blob
def _value(kind, payload):
if kind == 'array':
return [payload, None, b'']
if kind == 'map':
return [('object', payload), ('null', None), ('empty', b'')]
return payload
def _table(tmp_path, kind, external=False, extra=None, payload_type=None):
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('db', True)
fields = [DataField(0, 'id', AtomicType('INT')),
DataField(1, 'pt', AtomicType('STRING')), DataField(2, 'payload', payload_type or _type(kind))]
options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': 'true', 'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true', 'target-file-row-num': '2',
'blob.target-file-size': '32 B', 'blob.copy-buffer-size': '3 B'}
if external:
options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
options['data-file.external-paths.strategy'] = 'round-robin'
options.update(extra or {})
catalog.create_table('db.t', Schema(fields, partition_keys=['pt'], options=options), False)
return catalog.get_table('db.t')
def _read(table):
builder = table.new_read_builder()
read = builder.new_read()
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read fallback')
):
return sorted(read.to_arrow(builder.new_scan().plan().splits()).to_pylist(), key=lambda row: row['id'])
def _commit(builder, messages, stream, identifier=7):
commit = builder.new_commit()
try:
commit.commit(messages, identifier) if stream else commit.commit(messages)
finally:
commit.close()
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('external', [False, True])
def test_blob_rows_mix_with_arrow_and_release_sources(tmp_path, kind, stream, external):
table = _table(tmp_path, kind, external)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer = builder.new_write()
blob = StreamBlob(b'streamed payload')
empty = b'' if kind == 'scalar' else []
expected = [dict(id=0, pt='a', payload=_value(kind, b'arrow')),
dict(id=1, pt='a', payload=_value(kind, blob.payload)),
dict(id=2, pt='b', payload=empty), dict(id=3, pt='b', payload=None)]
try:
assert isinstance(writer, NativeTableWrite)
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
writer.write_arrow(pa.Table.from_pylist(
expected[:1], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
writer.write_row(GenericRow([1, 'a', _value(kind, blob)], table.fields))
writer.write_row(GenericRow([2, 'b', empty], table.fields))
writer.write_row(GenericRow([3, 'b', None], table.fields))
assert blob.opened == 1
assert all(source.closed and source.read_sizes for source in blob.streams)
assert writer._execution._blob_rows.readers == {}
assert writer._python_writer is None
messages = writer.prepare_commit(7) if stream else writer.prepare_commit()
_commit(builder, messages, stream)
assert _read(table) == expected
finally:
writer.close()
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
def test_stream_blob_rows_keep_routing_after_multiple_prepares(tmp_path, kind):
table = _table(tmp_path, kind)
builder = table.new_stream_write_builder()
writer = builder.new_write()
expected = []
try:
for identifier in [1, 2]:
blob = StreamBlob(('payload-%d' % identifier).encode())
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
writer.write_row(GenericRow([identifier, 'a', _value(kind, blob)], table.fields))
assert blob.opened == 1 and all(source.closed for source in blob.streams)
assert writer._execution._blob_rows.readers == {}
_commit(builder, writer.prepare_commit(identifier), True, identifier)
expected.append(dict(id=identifier, pt='a', payload=_value(kind, blob.payload)))
assert _read(table) == expected
finally:
writer.close()
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
def test_blob_row_subset_uses_named_fields_without_opening_omitted_blobs(tmp_path, kind):
table = _table(tmp_path, kind)
builder = table.new_batch_write_builder()
writer = builder.new_write().with_write_type(['pt', 'id'])
omitted = StreamBlob(b'ignored', AssertionError('omitted Blob opened'))
try:
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
writer.write_row(GenericRow([_value(kind, omitted), 1, 'a'],
[table.fields[2], table.fields[0], table.fields[1]]))
assert omitted.opened == 0
_commit(builder, writer.prepare_commit(), False)
assert _read(table) == [dict(id=1, pt='a', payload=None)]
finally:
writer.close()
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('blob_object', [False, True])
def test_blob_row_map_preserves_java_null_keys(tmp_path, stream, blob_object):
from pypaimon.execution.python.read.reader.format_blob_reader import (FormatBlobReader)
table = _table(tmp_path, 'map', extra={'blob.target-file-size': '1 MB'})
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer = builder.new_write()
value = StreamBlob(b'NULL key') if blob_object else b'NULL key'
try:
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
writer.write_row(GenericRow([1, 'a', [(None, value), ('normal', None)]], table.fields))
messages = writer.prepare_commit(7) if stream else writer.prepare_commit()
_commit(builder, messages, stream)
files = [file for message in messages for file in message.new_files if file.file_name.endswith('.blob')]
assert len(files) == 1
reader = FormatBlobReader(table.file_io, files[0].file_path, ['payload'], [table.fields[2]], None, False)
try:
row = reader.read_values_at([0])[0]
assert row[None].to_data() == b'NULL key' and row['normal'] is None
finally:
reader.close()
finally:
writer.close()
def test_duplicate_blob_map_keys_are_rejected_before_opening_payloads(tmp_path):
table = _table(tmp_path, 'map')
writer = table.new_batch_write_builder().new_write()
first = StreamBlob(b'first', AssertionError('duplicate map opened'))
second = StreamBlob(b'second', AssertionError('duplicate map opened'))
try:
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
with pytest.raises(ValueError, match='unique'):
writer.write_row(GenericRow([1, 'a', [(None, first), (None, second)]], table.fields))
assert first.opened == second.opened == 0
assert writer._execution._blob_rows.readers == {}
assert not list(tmp_path.rglob('*.blob'))
finally:
writer.close()
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
def test_blob_row_stream_error_preserves_prepared_files_and_exception(tmp_path, kind):
table = _table(tmp_path, kind)
builder = table.new_stream_write_builder()
writer = builder.new_write()
error = OSError('original Python stream failure')
bad = StreamBlob(b'bad', error)
try:
writer.write_row(GenericRow([0, 'a', _value(kind, b'prepared')], table.fields))
messages = writer.prepare_commit(1)
prepared = {path for path in tmp_path.rglob('*') if path.is_file() and path.suffix in ('.parquet', '.blob')}
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
with pytest.raises(OSError) as caught:
writer.write_row(GenericRow([1, 'a', _value(kind, bad)], table.fields))
assert caught.value is error
assert writer._execution._blob_rows.readers == {}
writer.abort()
assert all(path.exists() for path in prepared)
_commit(builder, messages, True, 1)
assert _read(table) == [dict(id=0, pt='a', payload=_value(kind, b'prepared'))]
finally:
writer.close()
def test_blob_object_stream_and_native_file_descriptor_share_one_writer(tmp_path):
table = _table(tmp_path, 'scalar')
source = tmp_path / 'source'
source.write_bytes(b'prefixREFERENCEDsuffix')
reference = BlobDescriptor(source.as_uri(), 6, 10).serialize()
blob = StreamBlob(b'OBJECT')
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
writer.write_row(GenericRow([0, 'a', blob], table.fields))
writer.write_row(GenericRow([1, 'a', reference], table.fields))
assert writer._execution._blob_rows.readers == {}
_commit(builder, writer.prepare_commit(), False)
assert _read(table) == [dict(id=0, pt='a', payload=b'OBJECT'),
dict(id=1, pt='a', payload=b'REFERENCED')]
finally:
writer.close()
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
@pytest.mark.parametrize('stream', [False, True])
def test_row_upsert_matches_and_deduplicates_before_opening_blob_streams(tmp_path, kind, stream):
from pypaimon.tests.native_blob_update_test import _native_only
table = _table(tmp_path, kind)
seed_builder = table.new_batch_write_builder()
seed = seed_builder.new_write()
try:
seed.write_arrow(pa.Table.from_pylist([
dict(id=0, pt='a', payload=_value(kind, b'old')),
dict(id=1, pt='a', payload=_value(kind, b'keep')),
], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
_commit(seed_builder, seed.prepare_commit(), False)
finally:
seed.close()
matched = StreamBlob(b'MATCHED')
appended = StreamBlob(b'APPENDED')
shadowed = StreamBlob(b'bad', AssertionError('Shadowed upsert source opened'))
source = [GenericRow([0, 'a', _value(kind, shadowed)], table.fields),
GenericRow([2, 'b', _value(kind, shadowed)], table.fields),
GenericRow([0, 'a', _value(kind, matched)], table.fields),
GenericRow([2, 'b', _value(kind, appended)], table.fields)]
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
with _native_only():
update = builder.new_update().with_update_type(['payload'])
messages = update.upsert_by_key(source, ['id'], 7) if stream else update.upsert_by_key(source, ['id'])
assert shadowed.opened == 0
assert matched.opened == appended.opened == 1
assert all(source.closed for blob in (matched, appended) for source in blob.streams)
_commit(builder, messages, stream)
assert _read(table) == [dict(id=0, pt='a', payload=_value(kind, b'MATCHED')),
dict(id=1, pt='a', payload=_value(kind, b'keep')),
dict(id=2, pt='b', payload=_value(kind, b'APPENDED'))]
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
def test_row_upsert_source_error_is_not_retried_through_python(tmp_path, kind):
from pypaimon.tests.native_blob_update_test import _native_only
table = _table(tmp_path, kind)
bad = StreamBlob(b'bad', OSError('upsert Blob source failed'))
builder = table.new_batch_write_builder()
with _native_only(), pytest.raises(OSError) as caught:
builder.new_update().upsert_by_key([GenericRow([0, 'a', _value(kind, bad)], table.fields)], ['id'])
assert caught.value is bad.error
assert bad.opened == 1
assert table.snapshot_manager().get_latest_snapshot() is None
assert not list(tmp_path.rglob('*.blob'))
@pytest.mark.parametrize('kind', ['scalar', 'array', 'map'])
def test_blob_row_missing_partition_is_rejected_without_opening_streams(tmp_path, kind):
table = _table(tmp_path, kind)
blob = StreamBlob(b'not opened')
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises(ValueError, match='pt'):
writer.write_row(GenericRow([0, _value(kind, blob)], [table.fields[0], table.fields[2]]))
assert blob.opened == 0
assert not list(tmp_path.rglob('*.blob'))
writer.write_row(GenericRow([0, 'a', _value(kind, blob)], table.fields))
assert blob.opened == 1
finally:
writer.close()
@pytest.mark.parametrize('operation', ['write', 'upsert'])
@pytest.mark.parametrize('entry', ['ab', b'ab', None, {'a': b'value'}])
def test_blob_map_rows_reject_non_pair_entries(tmp_path, operation, entry):
table = _table(tmp_path, 'map')
blob = StreamBlob(b'not opened')
row = GenericRow([0, 'a', [('valid', blob), entry]], table.fields)
builder = table.new_batch_write_builder()
with pytest.raises(ValueError, match='key/value pairs'):
if operation == 'upsert':
builder.new_update().upsert_by_key([row], ['id'])
else:
writer = builder.new_write()
try:
writer.write_row(row)
finally:
writer.close()
assert blob.opened == 0
assert not list(tmp_path.rglob('*.blob'))
@pytest.mark.parametrize('operation', ['write', 'upsert'])
@pytest.mark.parametrize('type_name, key, expected', [
('DECIMAL(10,2)', '12.345', '12.35'),
('DECIMAL(10,2)', '-12.345', '-12.35'),
('DECIMAL(38,18)', '12345678901234567890.1234567890123456785',
'12345678901234567890.123456789012345679'),
('DECIMAL(10,2)', '0.004999999999999999999999999999999999999999999', '0.00'),
])
def test_blob_map_row_decimal_keys_round_in_core(tmp_path, operation, type_name, key, expected):
from pypaimon.tests.native_blob_update_test import _native_only
from pypaimon.execution.python.read.reader.format_blob_reader import (FormatBlobReader)
table = _table(tmp_path, 'map', payload_type=MapType(True, AtomicType(type_name), AtomicType('BLOB')))
builder = table.new_batch_write_builder()
row = GenericRow([0, 'a', [(Decimal(key), StreamBlob(b'value'))]], table.fields)
if operation == 'upsert':
seed = builder.new_write()
try:
seed.write_row(GenericRow([0, 'a', [(Decimal('1'), b'old')]], table.fields))
_commit(builder, seed.prepare_commit(), False)
finally:
seed.close()
with _native_only():
if operation == 'upsert':
messages = builder.new_update().with_update_type(['payload']).upsert_by_key([row], ['id'])
else:
writer = builder.new_write()
try:
writer.write_row(row)
messages = writer.prepare_commit()
finally:
writer.close()
_commit(builder, messages, False)
file = next(file for message in messages for file in message.new_files if file.file_name.endswith('.blob'))
reader = FormatBlobReader(table.file_io, file.file_path, ['payload'], [table.fields[2]], None, False)
try:
result = reader.read_values_at([0])[0]
assert list(result) == [Decimal(expected)]
assert result[Decimal(expected)].to_data() == b'value'
finally:
reader.close()
@pytest.mark.parametrize('option', ['blob-descriptor-field', 'blob.stored-descriptor-fields', 'blob-view-field'])
@pytest.mark.parametrize('stream', [False, True])
def test_inline_blob_row_objects_keep_real_references(tmp_path, native_rest_catalog, option, stream):
from pypaimon.tests.native_blob_update_test import _inline_table, _read as read_inline
table, _, references = _inline_table(tmp_path, option, catalog=native_rest_catalog)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer = builder.new_write()
reference = references[0]
value = (Blob.from_view(BlobViewStruct.deserialize(reference)) if option == 'blob-view-field'
else Blob.from_descriptor_bytes(reference, table.file_io))
try:
with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')):
with patch.object(value, 'new_input_stream', side_effect=AssertionError('Inline reference opened')):
writer.write_row(GenericRow([4, 'a', 14, value], table.fields))
assert writer._execution._blob_rows.readers == {}
messages = writer.prepare_commit(7) if stream else writer.prepare_commit()
assert all(not file.file_name.endswith('.blob') for message in messages for file in message.new_files)
_commit(builder, messages, stream)
rows = read_inline(table, descriptors=True, resolve_views=False)
assert rows[-1] == dict(id=4, pt='a', value=14, payload=reference)
finally:
writer.close()
@pytest.mark.parametrize('key', ['99999999.995', '-99999999.995'])
def test_decimal_blob_map_overflow_is_rejected_before_opening_payload(tmp_path, key):
table = _table(tmp_path, 'map', payload_type=MapType(True, AtomicType('DECIMAL(10,2)'), AtomicType('BLOB')))
blob = StreamBlob(b'not opened')
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises(ValueError, match='precision'):
writer.write_row(GenericRow([0, 'a', [(Decimal(key), blob)]], table.fields))
assert blob.opened == 0
assert writer._execution._blob_rows.readers == {}
assert not list(tmp_path.rglob('*.blob'))
finally:
writer.close()
def test_decimal_blob_map_duplicate_keys_are_checked_after_rounding(tmp_path):
table = _table(tmp_path, 'map', payload_type=MapType(True, AtomicType('DECIMAL(10,2)'), AtomicType('BLOB')))
blob = StreamBlob(b'not opened')
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises(ValueError, match='unique'):
writer.write_row(GenericRow([0, 'a', [(Decimal('12.345'), blob), (Decimal('12.35'), blob)]], table.fields))
assert blob.opened == 0
assert writer._execution._blob_rows.readers == {}
assert not list(tmp_path.rglob('*.blob'))
finally:
writer.close()