| # 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. | |
| """Blob callbacks must retain Native writes and report Java payload ranges.""" | |
| import pyarrow as pa | |
| import pytest | |
| from urllib.parse import urlparse | |
| from unittest.mock import patch | |
| from pypaimon import CatalogFactory, Schema | |
| from pypaimon.schema.data_types import AtomicType, ArrayType, DataField, MapType, PyarrowFieldParser | |
| from pypaimon.table.row.blob import Blob, BlobDescriptor | |
| from pypaimon.table.row.generic_row import GenericRow | |
| from pypaimon.tests.native_blob_write_test import _table, _data, _read | |
| from pypaimon.write.write_dispatch import NativeTableWrite | |
| pytestmark = pytest.mark.native_plan | |
| def test_blob_consumer_keeps_native_writer(tmp_path): | |
| table = _table(tmp_path) | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| received = [] | |
| def consumer(field_name, descriptor): | |
| received.append((field_name, descriptor)) | |
| return True | |
| try: | |
| assert writer.with_blob_consumer(consumer) is writer | |
| assert isinstance(writer, NativeTableWrite) | |
| writer.write_arrow(_data()) | |
| messages = writer.prepare_commit() | |
| commit.commit(messages) | |
| assert writer._python_writer is None | |
| assert [descriptor.length if descriptor is not None else None | |
| for name, descriptor in received if name == 'large'] == [40, None] | |
| assert [descriptor.length for name, descriptor in received if name == 'small'] == [0, 3] | |
| assert _read(table, True, True) == _data().to_pylist() | |
| finally: | |
| writer.close() | |
| commit.close() | |
| def _collection_table(tmp_path, native, external=False, options=None): | |
| catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')}) | |
| catalog.create_database('db', True) | |
| blob = AtomicType('BLOB') | |
| fields = [DataField(0, 'id', AtomicType('INT')), | |
| DataField(1, 'payload', blob), | |
| DataField(2, 'items', ArrayType(True, blob)), | |
| DataField(3, 'attrs', MapType(True, AtomicType('STRING'), blob))] | |
| settings = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true', | |
| 'write.native.enabled': str(native).lower(), 'target-file-row-num': '2', | |
| 'blob.target-file-size': '32 B', 'blob.copy-buffer-size': '3 B', | |
| 'data-file.path-directory': 'data'} | |
| if external: | |
| settings['data-file.external-paths'] = (tmp_path / 'external').as_uri() | |
| settings['data-file.external-paths.strategy'] = 'round-robin' | |
| settings.update(options or {}) | |
| catalog.create_table('db.t', Schema(fields=fields, options=settings), False) | |
| return catalog.get_table('db.t') | |
| def _payload_at(descriptor): | |
| path = urlparse(descriptor.uri).path if descriptor.uri.startswith('file:') else descriptor.uri | |
| with open(path, 'rb') as source: | |
| source.seek(descriptor.offset) | |
| return source.read(descriptor.length) | |
| @pytest.mark.parametrize('native', [False, True]) | |
| @pytest.mark.parametrize('stream', [False, True]) | |
| @pytest.mark.parametrize('external', [False, True]) | |
| @pytest.mark.parametrize('reference', [False, True]) | |
| def test_blob_consumer_ranges_nulls_and_collection_elements(tmp_path, native, stream, external, reference): | |
| table = _collection_table(tmp_path, native, external) | |
| received = [] | |
| def consumer(name, descriptor): | |
| received.append((name, descriptor)) | |
| return len(received) % 2 == 1 | |
| builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| expected = [] | |
| try: | |
| assert writer.with_blob_consumer(consumer) is writer | |
| for identifier in range(1, 3 if stream else 2): | |
| received.clear() | |
| value = b'payload-with-many-copy-chunks' | |
| if reference: | |
| path = tmp_path / 'source' | |
| path.write_bytes(b'prefix' + value + b'suffix') | |
| value_input = BlobDescriptor(str(path), 6, len(value)).serialize() | |
| else: | |
| value_input = value | |
| rows = [{'id': identifier * 3, 'payload': value_input, | |
| 'items': [b'a', None, b''], 'attrs': [('key', value_input), ('null', None)]}, | |
| {'id': identifier * 3 + 1, 'payload': None, 'items': None, 'attrs': None}, | |
| {'id': identifier * 3 + 2, 'payload': b'', 'items': [], 'attrs': []}] | |
| expected.extend([dict(rows[0], payload=value, attrs=[('key', value), ('null', None)]), | |
| rows[1], rows[2]]) | |
| writer.write_arrow(pa.Table.from_pylist( | |
| rows, schema=PyarrowFieldParser.from_paimon_schema(table.fields))) | |
| messages = writer.prepare_commit(identifier) if stream else writer.prepare_commit() | |
| for name, values in [('payload', [value, None, b'']), | |
| ('items', [b'a', b'', None]), ('attrs', [value, None])]: | |
| descriptors = [descriptor for field, descriptor in received if field == name] | |
| assert len(descriptors) == len(values) | |
| for descriptor, payload in zip(descriptors, values): | |
| if payload is None: | |
| assert descriptor is None | |
| else: | |
| assert isinstance(descriptor, BlobDescriptor) | |
| assert descriptor.length == len(payload) | |
| assert _payload_at(descriptor) == payload | |
| assert ('/external/' in descriptor.uri) == external | |
| commit.commit(messages, identifier) if stream else commit.commit(messages) | |
| for planner in (False, True): | |
| for reader in (False, True): | |
| assert _read(table, planner, reader) == expected | |
| if native: | |
| assert writer._python_writer is None | |
| finally: | |
| writer.close() | |
| commit.close() | |
| def test_blob_consumer_failure_preserves_exception_and_does_not_retry(tmp_path): | |
| table = _collection_table(tmp_path, True) | |
| writer = table.new_batch_write_builder().new_write() | |
| calls = [] | |
| failure = RuntimeError('consumer stopped the write') | |
| def consumer(name, descriptor): | |
| calls.append((name, descriptor)) | |
| raise failure | |
| try: | |
| writer.with_blob_consumer(consumer) | |
| schema = PyarrowFieldParser.from_paimon_schema(table.fields) | |
| data = pa.Table.from_pylist([{'id': 1, 'payload': b'a'}, {'id': 2, 'payload': b'b'}], schema=schema) | |
| with pytest.raises(RuntimeError) as caught: | |
| writer.write_arrow(data) | |
| assert caught.value is failure | |
| assert len(calls) == 1 | |
| assert writer._python_writer is None | |
| assert table.snapshot_manager().get_latest_snapshot() is None | |
| # Java relinquishes deletion rights as soon as a consumer is installed. | |
| # The descriptor may already have been handed to another application. | |
| assert list(tmp_path.rglob('*.blob')) | |
| assert _payload_at(calls[0][1]) == b'a' | |
| assert not list(tmp_path.rglob('*.parquet')) | |
| with pytest.raises(Exception, match='write failure'): | |
| writer.write_arrow(data) | |
| assert len(calls) == 1 | |
| finally: | |
| writer.close() | |
| @pytest.mark.parametrize('native', [False, True]) | |
| @pytest.mark.parametrize('action', ['close', 'abort', 'callback-failure']) | |
| def test_blob_consumer_preserves_uncommitted_exposed_files(tmp_path, native, action): | |
| table = _table(tmp_path, {'write.native.enabled': str(native).lower(), | |
| 'target-file-row-num': '1'}) | |
| writer = table.new_batch_write_builder().new_write() | |
| received = [] | |
| def consumer(name, descriptor): | |
| received.append((name, descriptor)) | |
| if action == 'callback-failure' and descriptor is None: | |
| raise RuntimeError('after exposing a payload') | |
| return True | |
| try: | |
| writer.with_blob_consumer(consumer) | |
| if action == 'callback-failure': | |
| with pytest.raises(RuntimeError, match='after exposing'): | |
| writer.write_arrow(_data()) | |
| else: | |
| writer.write_arrow(_data()) | |
| getattr(writer, action)() | |
| assert table.snapshot_manager().get_latest_snapshot() is None | |
| for _, descriptor in received: | |
| if descriptor is not None: | |
| assert _payload_at(descriptor) in (b'\x01' * 40, b'', b'abc') | |
| assert list(tmp_path.rglob('*.blob')) | |
| if native: | |
| assert not list(tmp_path.rglob('*.parquet')) | |
| assert writer._python_writer is None | |
| finally: | |
| writer.close() | |
| def test_blob_consumer_flush_makes_local_payload_visible_before_prepare(tmp_path): | |
| table = _table(tmp_path, {'target-file-row-num': '1000', 'blob.target-file-size': '1 MB'}) | |
| writer = table.new_batch_write_builder().new_write() | |
| received = [] | |
| try: | |
| writer.with_blob_consumer(lambda name, descriptor: (received.append((name, descriptor)) or True)) | |
| writer.write_arrow(_data().slice(0, 1)) | |
| # No roll or prepare can hide a no-op flush by closing the output. | |
| assert [_payload_at(descriptor) for _, descriptor in received] == [b'\x01' * 40, b''] | |
| assert table.snapshot_manager().get_latest_snapshot() is None | |
| assert writer._python_writer is None | |
| finally: | |
| writer.close() | |
| @pytest.mark.parametrize('native', [False, True]) | |
| @pytest.mark.parametrize('stream', [False, True]) | |
| def test_blob_consumer_can_be_replaced_and_cleared_before_and_after_write(tmp_path, native, stream): | |
| table = _table(tmp_path, {'write.native.enabled': str(native).lower()}) | |
| builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| received = [] | |
| try: | |
| if native: | |
| assert isinstance(writer, NativeTableWrite) | |
| writer.with_blob_consumer(lambda name, descriptor: pytest.fail('replaced consumer invoked')) | |
| with pytest.raises(TypeError, match='callable'): | |
| writer.with_blob_consumer(123) | |
| writer.with_blob_consumer(None) | |
| writer.write_arrow(_data()) | |
| assert writer.with_blob_consumer(lambda name, descriptor: received.append((name, descriptor))) is writer | |
| writer.write_arrow(_data(2)) | |
| # Java updates the factory setting; the existing bucket retains the | |
| # cleared consumer it captured when its writer was first created. | |
| assert not received | |
| assert writer.with_blob_consumer(None) is writer | |
| if stream: | |
| commit.commit(writer.prepare_commit(1), 1) | |
| else: | |
| commit.commit(writer.prepare_commit()) | |
| assert _read(table, True, True) == _data().to_pylist() + _data(2).to_pylist() | |
| if native: | |
| assert writer._python_writer is None | |
| finally: | |
| writer.close() | |
| commit.close() | |
| def test_blob_consumer_survives_switch_to_row_writer(tmp_path): | |
| table = _table(tmp_path) | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| received = [] | |
| try: | |
| writer.with_blob_consumer(lambda name, descriptor: received.append((name, descriptor))) | |
| row = GenericRow([1, Blob.from_data(b'row-payload'), None], table.fields) | |
| writer.write_row(row) | |
| messages = writer.prepare_commit() | |
| commit.commit(messages) | |
| assert [(name, None if descriptor is None else _payload_at(descriptor)) | |
| for name, descriptor in received] == [('large', b'row-payload'), ('small', None)] | |
| finally: | |
| writer.close() | |
| commit.close() | |
| def test_blob_consumer_reentrant_native_read_and_write_type_projection(tmp_path): | |
| table = _table(tmp_path) | |
| first = table.new_batch_write_builder() | |
| initial = first.new_write() | |
| initial.write_arrow(_data()) | |
| first.new_commit().commit(initial.prepare_commit()) | |
| initial.close() | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| received = [] | |
| def consumer(name, descriptor): | |
| assert _read(table, True, True) == _data().to_pylist() | |
| received.append((name, descriptor)) | |
| return False | |
| try: | |
| writer.with_blob_consumer(consumer).with_write_type(['id', 'small']) | |
| with patch.object(writer, '_switch_to_python', side_effect=AssertionError('Python writer fallback')): | |
| writer.write_arrow(_data(2)) | |
| commit.commit(writer.prepare_commit()) | |
| assert [name for name, _ in received] == ['small', 'small'] | |
| assert [_payload_at(descriptor) for _, descriptor in received] == [b'', b'abc'] | |
| assert writer._python_writer is None | |
| finally: | |
| writer.close() | |
| commit.close() |