| # 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. | |
| """Native external data files interoperate with Python readers and committers.""" | |
| from contextlib import ExitStack | |
| from pathlib import Path | |
| from unittest.mock import patch | |
| import pyarrow as pa | |
| import pytest | |
| from pypaimon import CatalogFactory, Schema | |
| from pypaimon.execution.native.read.plan import (native_plan) | |
| from pypaimon.write.write_dispatch import NativeTableWrite | |
| pytestmark = pytest.mark.native_plan | |
| _SCHEMA = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('value', pa.int32())]) | |
| def _table(tmp_path, mode, strategy, native, partitioned=True, extra=None): | |
| catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')}) | |
| catalog.create_database('db', True) | |
| paths = [(tmp_path / name).as_uri() for name in ['external-a', 'external-b']] | |
| if strategy == 'specific-fs': | |
| paths.insert(0, 's3://unused-bucket/data') | |
| options = {'write.native.enabled': str(native).lower(), | |
| 'data-file.external-paths': ','.join(paths), | |
| 'data-file.external-paths.strategy': strategy, | |
| 'data-file.external-paths.weights': '1,2', | |
| 'data-file.external-paths.specific-fs': 'FILE', | |
| 'data-file.path-directory': 'data/nested'} | |
| partitions = ['pt'] if partitioned else [] | |
| keys = [] | |
| if mode == 'pk': | |
| keys = ['id'] + partitions | |
| options.update({'bucket': '1', 'changelog-producer': 'input'}) | |
| elif mode == 'evolution': | |
| options.update({'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true', | |
| 'deletion-vectors.enabled': 'true', 'target-file-row-num': '1', | |
| 'index-file-in-data-file-dir': 'true'}) | |
| options.update(extra or {}) | |
| catalog.create_table('db.t', Schema.from_pyarrow_schema( | |
| _SCHEMA, primary_keys=keys, partition_keys=partitions, options=options), False) | |
| return catalog.get_table('db.t') | |
| def _write(table, data, native, streaming=False, identifier=1): | |
| builder = table.new_stream_write_builder() if streaming else table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| assert isinstance(writer, NativeTableWrite) == native | |
| writer.write_arrow(data) | |
| messages = writer.prepare_commit(identifier) if streaming else writer.prepare_commit() | |
| for message in messages: | |
| for file in message.new_files + message.changelog_files: | |
| assert bool(file.external_path) == (table.options.data_file_external_paths_strategy() != 'none') | |
| assert table.file_io.exists(file.file_path), file.file_path | |
| if file.external_path: | |
| assert '/external-' in file.external_path | |
| assert not Path(table.table_path, file.file_name).exists() | |
| if streaming: | |
| commit.commit(messages, identifier) | |
| else: | |
| commit.commit(messages) | |
| if native: | |
| assert writer._python_writer is None | |
| return messages | |
| finally: | |
| writer.close() | |
| commit.close() | |
| def _read(table, planner, reader, snapshot=None): | |
| options = {'scan.native-plan.enabled': str(planner).lower(), | |
| 'read.native.enabled': str(reader).lower()} | |
| if snapshot is not None: | |
| options['scan.snapshot-id'] = str(snapshot) | |
| table = table.copy(options) | |
| builder = table.new_read_builder() | |
| plan = native_plan(table) if planner else builder.new_scan().plan() | |
| read = builder.new_read() | |
| with ExitStack() as stack: | |
| if reader: | |
| stack.enter_context(patch( | |
| 'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read', | |
| side_effect=AssertionError('Python read fallback') | |
| )) | |
| result = read.to_arrow(plan.splits()) | |
| return sorted(result.to_pylist(), key=lambda row: row['id']) | |
| @pytest.mark.parametrize('mode', ['append', 'pk', 'evolution']) | |
| @pytest.mark.parametrize('strategy', ['none', 'round-robin', 'weight-robin', 'entropy-inject', 'specific-fs']) | |
| @pytest.mark.parametrize('native', [False, True]) | |
| @pytest.mark.parametrize('partitioned', [False, True]) | |
| def test_external_batch_and_stream_interoperability(tmp_path, mode, strategy, native, partitioned): | |
| table = _table(tmp_path, mode, strategy, native, partitioned) | |
| first = pa.table({'id': [1, 2], 'pt': ['a', 'b'], 'value': [10, 20]}, schema=_SCHEMA) | |
| second = pa.table({'id': [3], 'pt': ['a'], 'value': [30]}, schema=_SCHEMA) | |
| _write(table, first, native) | |
| _write(table, second, native, streaming=True, identifier=2) | |
| # Existing files must use their recorded paths after changing write destinations. | |
| table = table.copy({'data-file.external-paths': (tmp_path / 'new-location').as_uri()}) | |
| for planner in (False, True): | |
| for reader in (False, True): | |
| assert _read(table, planner, reader) == first.to_pylist() + second.to_pylist() | |
| @pytest.mark.parametrize('strategy', ['round-robin', 'entropy-inject']) | |
| def test_native_external_escaped_partition_and_abort(tmp_path, strategy): | |
| table = _table(tmp_path, 'append', strategy, True) | |
| data = pa.table({'id': [1], 'pt': ['a/b%?#'], 'value': [10]}, schema=_SCHEMA) | |
| _write(table, data, True) | |
| for planner in (False, True): | |
| for reader in (False, True): | |
| assert _read(table, planner, reader) == data.to_pylist() | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| writer.write_arrow(data) | |
| messages = writer.prepare_commit() | |
| files = [file for message in messages for file in message.new_files] | |
| assert files and all(table.file_io.exists(file.file_path) for file in files) | |
| commit.abort(messages) | |
| assert not any(table.file_io.exists(file.file_path) for file in files) | |
| finally: | |
| writer.close() | |
| commit.close() | |
| assert _read(table, True, True) == data.to_pylist() | |
| @pytest.mark.parametrize('strategy', ['round-robin', 'entropy-inject']) | |
| @pytest.mark.parametrize('native', [False, True]) | |
| def test_external_updates_upserts_deletes_history_and_abort(tmp_path, strategy, native): | |
| table = _table(tmp_path, 'evolution', strategy, native) | |
| original = pa.table({'id': [1, 2, 3], 'pt': ['a'] * 3, 'value': [10, 20, 30]}, schema=_SCHEMA) | |
| _write(table, original, native) | |
| builder = table.new_batch_write_builder() | |
| update = builder.new_update().with_update_type(['value']) | |
| messages = update.upsert_by_arrow_with_key(pa.table( | |
| {'id': [1, 4], 'pt': ['a', 'b'], 'value': [11, 40]}, schema=_SCHEMA), ['id']) | |
| commit = builder.new_commit() | |
| try: | |
| commit.commit(messages) | |
| finally: | |
| commit.close() | |
| for row_id, expected in [(0, [20, 30, 40]), (1, [30, 40])]: | |
| messages = builder.new_update().delete_by_row_id([row_id]) | |
| commit = builder.new_commit() | |
| try: | |
| commit.commit(messages) | |
| finally: | |
| commit.close() | |
| for planner in (False, True): | |
| for reader in (False, True): | |
| assert [row['value'] for row in _read(table, planner, reader)] == expected | |
| messages = builder.new_update().delete_by_row_id([2]) | |
| staged = [table.path_factory().bucket_index_path( | |
| tuple(entry.partition.values), entry.bucket, entry.index_file) | |
| for message in messages for entry in message.index_adds] | |
| assert staged and all(table.file_io.exists(path) for path in staged) | |
| commit = builder.new_commit() | |
| try: | |
| commit.abort(messages) | |
| finally: | |
| commit.close() | |
| assert not any(table.file_io.exists(path) for path in staged) | |
| assert _read(table, True, True, snapshot=1) == original.to_pylist() | |
| assert [row['value'] for row in _read(table, True, True)] == [30, 40] | |
| @pytest.mark.parametrize('strategy', ['none', 'round-robin', 'entropy-inject']) | |
| @pytest.mark.parametrize('native', [False, True]) | |
| def test_external_blob_writer_and_readers(tmp_path, strategy, native): | |
| from pypaimon.schema.data_types import AtomicType, DataField | |
| table = _table(tmp_path, 'evolution', strategy, native, partitioned=False) | |
| catalog = table.catalog_environment.catalog_loader.load() | |
| catalog.drop_table('db.t') | |
| fields = [DataField(0, 'id', AtomicType('INT')), | |
| DataField(1, 'first', AtomicType('BLOB')), DataField(2, 'second', AtomicType('BLOB'))] | |
| catalog.create_table('db.t', Schema(fields=fields, options=table.table_schema.options), False) | |
| table = catalog.get_table('db.t') | |
| data = pa.table({'id': pa.array([1, 2, 3], pa.int32()), | |
| 'first': pa.array([b'hello', None, b''], pa.large_binary()), | |
| 'second': pa.array([None, b'world', b'!'], pa.large_binary())}) | |
| _write(table, data, native) | |
| for planner in (False, True): | |
| for reader in (False, True): | |
| assert _read(table, planner, reader) == data.to_pylist() | |
| @pytest.mark.parametrize('mode', ['append', 'pk', 'evolution']) | |
| def test_python_abort_removes_native_external_sidecars(tmp_path, mode): | |
| table = _table(tmp_path, mode, 'entropy-inject', True, extra={ | |
| 'commit.native.enabled': 'false', 'file-index.bloom-filter.columns': 'value', | |
| 'file-index.bloom-filter.value.items': '10', 'file-index.in-manifest-threshold': '0 B'}) | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| assert isinstance(writer, NativeTableWrite) | |
| writer.write_arrow(pa.table({'id': [1], 'pt': ['a/b%?#'], 'value': [10]}, schema=_SCHEMA)) | |
| messages = writer.prepare_commit() | |
| files = [file for message in messages for file in message.new_files] | |
| assert files and all(file.extra_files for file in files) | |
| paths = [path for message in messages for file in message.new_files + message.changelog_files | |
| for path in file.collect_files()] | |
| assert all(table.file_io.exists(path) for path in paths) | |
| commit.abort(messages) | |
| assert not any(table.file_io.exists(path) for path in paths) | |
| finally: | |
| writer.close() | |
| commit.close() |