| # 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 write/commit routing and failure ownership contracts.""" | |
| import json | |
| from types import ModuleType | |
| 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.execution.native.write.native_commit import _rest_catalog_supported | |
| from pypaimon.write.write_dispatch import NativeTableWrite | |
| pytestmark = [pytest.mark.python_write, pytest.mark.python_commit] | |
| native = pytest.mark.skipif(not native_runtime_available(), reason='pypaimon-rust not installed') | |
| _SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.string())]) | |
| def _table(tmp_path, catalog=None, options=None): | |
| catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')}) | |
| catalog.create_database('default', True) | |
| catalog.create_table('default.t', Schema.from_pyarrow_schema( | |
| _SCHEMA, partition_keys=['pt'], options=dict({ | |
| 'commit.native.enabled': 'true', 'write.native.enabled': 'false' | |
| }, **(options or {}))), False) | |
| return catalog.get_table('default.t') | |
| def _write(table, rows): | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA)) | |
| commit.commit(writer.prepare_commit()) | |
| finally: | |
| writer.close() | |
| commit.close() | |
| def _rows(table): | |
| builder = table.new_read_builder() | |
| return sorted(builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist(), | |
| key=lambda row: row['id']) | |
| @pytest.mark.parametrize('operation,args', [ | |
| ('truncate_table', ()), ('truncate_partitions', ([{'pt': 'a'}],)), | |
| ]) | |
| def test_native_truncate_publication_error_never_retries_or_aborts(tmp_path, operation, args): | |
| table = _table(tmp_path) | |
| commit = table.new_batch_write_builder().new_commit() | |
| native_commit = Mock() | |
| cause = RuntimeError('snapshot may have published before response failed') | |
| getattr(native_commit, operation).side_effect = cause | |
| try: | |
| with patch.object(commit, '_prepare_native_commit', return_value=(native_commit, [])), \ | |
| patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.truncate_table') as truncate, \ | |
| patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.drop_partitions') as drop, \ | |
| patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.abort') as abort: | |
| with pytest.raises(RuntimeError) as error: | |
| getattr(commit, operation)(*args) | |
| assert error.value is cause | |
| truncate.assert_not_called() | |
| drop.assert_not_called() | |
| abort.assert_not_called() | |
| native_commit.abort.assert_not_called() | |
| finally: | |
| commit.close() | |
| def test_abort_then_truncate_partitions_then_commit_keeps_batch_lifecycle(tmp_path): | |
| table = _table(tmp_path) | |
| commit = table.new_batch_write_builder().new_commit() | |
| stream, batch = Mock(), Mock() | |
| try: | |
| with patch( | |
| 'pypaimon.execution.native.write.native_commit.create_native_commit', | |
| side_effect=[stream, batch]) as create, \ | |
| patch('pypaimon.execution.native.write.native_commit.to_native_commit_messages', return_value=[]), \ | |
| patch('pypaimon.execution.native.write.native_commit.native_messages_supported', return_value=True), \ | |
| patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.commit') as fallback: | |
| commit.abort([]) | |
| commit.truncate_partitions([{'pt': 'a'}]) | |
| # Java truncatePartitions does not consume the one-shot batch commit. | |
| commit.table = table.copy({'snapshot.ignore-empty-commit': 'false'}) | |
| commit.commit([]) | |
| stream.abort.assert_called_once_with([]) | |
| stream.close.assert_called_once_with() | |
| batch.truncate_partitions.assert_called_once_with([{'pt': 'a'}]) | |
| batch.commit.assert_called_once_with([]) | |
| assert create.call_args_list[1][1] == {'batch': True} | |
| fallback.assert_not_called() | |
| with pytest.raises(RuntimeError, match='one-time'): | |
| commit.commit([]) | |
| finally: | |
| commit.close() | |
| def _bind_fuse(table, root, cached=False): | |
| from pypaimon.catalog.rest.fuse_support import FusePathResolver | |
| from pypaimon.filesystem.caching_file_io import CachingFileIO | |
| from pypaimon.filesystem.local_file_io import FuseLocalFileIO, LocalFileIO | |
| options = table.catalog_environment.catalog_loader.context().options | |
| options.data.update({'fuse.enabled': 'true', 'fuse.root': root.as_uri(), | |
| 'fuse.mode': 'pvfs', 'fuse.validation-mode': 'none'}) | |
| mount = LocalFileIO()._to_file(FusePathResolver(options, None).resolve_local_path( | |
| table.table_path, table.identifier)).absolute() | |
| mount.parent.mkdir(parents=True, exist_ok=True) | |
| mount.symlink_to(LocalFileIO()._to_file(table.table_path), target_is_directory=True) | |
| io = FuseLocalFileIO(table.table_path, str(mount), options) | |
| table.file_io = CachingFileIO(io, Mock()) if cached else io | |
| return mount | |
| @pytest.mark.parametrize('cached', [False, True]) | |
| def test_builtin_fuse_is_accepted_without_relaxing_custom_file_io(tmp_path, native_rest_catalog, cached): | |
| from pypaimon.filesystem.local_file_io import FuseLocalFileIO | |
| table = _table(tmp_path, native_rest_catalog) | |
| mount = _bind_fuse(table, tmp_path / 'fuse', cached) | |
| assert _rest_catalog_supported(table) | |
| class CustomFuse(FuseLocalFileIO): | |
| pass | |
| table.file_io = CustomFuse(table.table_path, str(mount)) | |
| assert not _rest_catalog_supported(table) | |
| @pytest.mark.parametrize('cached', [False, True]) | |
| @pytest.mark.parametrize('invalid', [None, 'source-uuid', 'source-path', 'target-path', 'target-branch']) | |
| def test_copied_fuse_write_uses_existing_copy_and_checks_identity( | |
| tmp_path, native_rest_catalog, cached, invalid): | |
| from pypaimon.execution.native.write.native_commit import create_native_write_table | |
| original = _table(tmp_path, native_rest_catalog) | |
| _bind_fuse(original, tmp_path / 'fuse', cached) | |
| table = original.copy_without_time_travel({'branch': 'dev'}) | |
| assert table.catalog_environment.uuid is None | |
| assert _rest_catalog_supported(table) | |
| module = ModuleType('pypaimon_rust.datafusion') | |
| module.Table = Mock() | |
| module.PaimonCatalog = Mock() | |
| source = Mock(spec=['location', 'rest_table_uuid', 'copy']) | |
| target = Mock(spec=['location', 'branch', 'copy_with_resolved_schema']) | |
| source.location.return_value = original.table_path if invalid != 'source-path' else '/different' | |
| source.rest_table_uuid.return_value = ( | |
| original.catalog_environment.uuid if invalid != 'source-uuid' else 'replacement-uuid') | |
| source.copy.return_value = target | |
| target.location.return_value = table.table_path if invalid != 'target-path' else '/different' | |
| target.branch.return_value = 'dev' if invalid != 'target-branch' else 'main' | |
| target.copy_with_resolved_schema.return_value = target | |
| module.PaimonCatalog.return_value.get_table.return_value = source | |
| with patch.dict('sys.modules', {'pypaimon_rust.datafusion': module}): | |
| result = create_native_write_table(table) | |
| module.PaimonCatalog.return_value.get_table.assert_called_once_with(('default', 't')) | |
| if invalid is None: | |
| assert result is target | |
| source.copy.assert_called_once_with({'branch': 'dev'}) | |
| target.copy_with_resolved_schema.assert_called_once() | |
| schema = json.loads(target.copy_with_resolved_schema.call_args[0][0]) | |
| assert schema['options']['branch'] == 'dev' | |
| else: | |
| assert result is None | |
| target.copy_with_resolved_schema.assert_not_called() | |
| if invalid.startswith('source-'): | |
| source.copy.assert_not_called() | |
| @pytest.mark.parametrize('identity', [None, '', 123]) | |
| def test_copied_fuse_write_requires_original_rest_uuid(tmp_path, native_rest_catalog, identity): | |
| table = _table(tmp_path, native_rest_catalog) | |
| _bind_fuse(table, tmp_path / 'fuse') | |
| table = table.copy_without_time_travel({'branch': 'dev'}) | |
| response = json.loads(table.catalog_environment.rest_table_response) | |
| response['id'] = identity | |
| table.catalog_environment.rest_table_response = json.dumps(response) | |
| assert not _rest_catalog_supported(table) | |
| @native | |
| @pytest.mark.parametrize('copied_branch', [False, True]) | |
| def test_fuse_native_write_commit_and_truncate_preserve_bound_mount( | |
| tmp_path, native_rest_catalog, copied_branch): | |
| table = _table(tmp_path, native_rest_catalog, {'write.native.enabled': 'true'}) | |
| _bind_fuse(table, tmp_path / 'fuse') | |
| if copied_branch: | |
| table.branch_manager().create_branch('dev') | |
| table = table.copy({'branch': 'dev'}) | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| assert isinstance(writer, NativeTableWrite) | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist([{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}], schema=_SCHEMA)) | |
| with patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.commit', | |
| side_effect=AssertionError('Python commit')): | |
| commit.commit(writer.prepare_commit()) | |
| finally: | |
| writer.close() | |
| commit.close() | |
| assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}] | |
| commit = table.new_batch_write_builder().new_commit() | |
| try: | |
| with patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.drop_partitions', | |
| side_effect=AssertionError('Python truncate')): | |
| commit.truncate_partitions([{'pt': 'a'}]) | |
| assert _rows(table) == [{'id': 2, 'pt': 'b'}] | |
| with patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.truncate_table', | |
| side_effect=AssertionError('Python truncate')): | |
| commit.truncate_table() | |
| assert _rows(table) == [] | |
| finally: | |
| commit.close() | |
| def _merge_table(tmp_path, use_native): | |
| schema = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('payload', pa.string())]) | |
| catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'merge')}) | |
| catalog.create_database('default', True) | |
| catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={ | |
| 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true', | |
| 'write.native.enabled': str(use_native).lower()}), False) | |
| table = catalog.get_table('default.t') | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist([ | |
| {'id': 1, 'value': 10, 'payload': 'old'}, {'id': 2, 'value': 20, 'payload': 'keep'} | |
| ], schema=schema)) | |
| commit.commit(writer.prepare_commit()) | |
| finally: | |
| writer.close() | |
| commit.close() | |
| return table | |
| @pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)]) | |
| @pytest.mark.parametrize('source_key', ['id', 'source_id']) | |
| @pytest.mark.parametrize('dictionary_key', [False, True]) | |
| def test_merge_callable_reads_target_source_and_coalesced_keys(tmp_path, use_native, source_key, dictionary_key): | |
| import pyarrow.compute as pc | |
| import pypaimon.execution.python.write.merge_into as merge_impl | |
| from pypaimon.table.data_evolution_merge_into import WhenMatched, WhenNotMatched | |
| table = _merge_table(tmp_path, use_native) | |
| keys = pa.array([1, 3], pa.int32()) | |
| source = pa.table({source_key: keys.dictionary_encode() if dictionary_key else keys, | |
| 'delta': pa.array([4, 5], pa.int32()), 'label': ['unused', 'new']}) | |
| calls = [] | |
| def value(batch): | |
| calls.append(batch.column_names) | |
| assert batch['s.' + source_key].type == pa.int32() | |
| return pc.add(pc.add(batch['t.value'], batch['s.delta']), batch['s.' + source_key]) | |
| update = table.new_batch_write_builder().new_update() | |
| with patch('pypaimon.execution.python.write.merge_into._build_tables', | |
| wraps=merge_impl._build_tables) as python_merge: | |
| messages = update.merge_into( | |
| source, on={'id': source_key}, | |
| when_matched=[WhenMatched.update({ | |
| 'value': value, 'payload': lambda batch: pc.utf8_upper(batch['t.payload'])})], | |
| when_not_matched=[WhenNotMatched({ | |
| 'value': lambda batch: pc.multiply(batch['s.delta'], 2), | |
| 'payload': lambda batch: batch['s.label']})]) | |
| if use_native: | |
| python_merge.assert_not_called() | |
| table.new_batch_write_builder().new_commit().commit(messages) | |
| assert _rows(table) == [{'id': 1, 'value': 15, 'payload': 'OLD'}, | |
| {'id': 2, 'value': 20, 'payload': 'keep'}, | |
| {'id': 3, 'value': 10, 'payload': 'new'}] | |
| assert len(calls) == 1 | |
| assert calls[0] == sorted(['t._ROW_ID', 't.id', 't.value', 't.payload', | |
| 's.' + source_key, 's.delta', 's.label']) | |
| @pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)]) | |
| def test_merge_callable_is_lazy_and_failure_preserves_committed_rows(tmp_path, use_native): | |
| from pypaimon.table.data_evolution_merge_into import WhenMatched | |
| table = _merge_table(tmp_path, use_native) | |
| cause = RuntimeError('callable failure') | |
| calls = [] | |
| def fail(batch): | |
| calls.append(batch.num_rows) | |
| raise cause | |
| update = table.new_batch_write_builder().new_update() | |
| clause = [WhenMatched.update({'value': fail})] | |
| assert update.merge_into(pa.table({'id': pa.array([99], pa.int32())}), on=['id'], | |
| when_matched=clause, when_not_matched=[]) == [] | |
| assert calls == [] | |
| with pytest.raises(RuntimeError) as error: | |
| update.merge_into(pa.table({'id': pa.array([1], pa.int32())}), on=['id'], | |
| when_matched=clause, when_not_matched=[]) | |
| assert error.value is cause | |
| assert calls == [1] | |
| assert _rows(table) == [{'id': 1, 'value': 10, 'payload': 'old'}, | |
| {'id': 2, 'value': 20, 'payload': 'keep'}] | |
| @pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)]) | |
| @pytest.mark.parametrize('result_kind', ['chunked', 'wrong_length', 'not_arrow']) | |
| def test_merge_callable_validates_complete_clause_result(tmp_path, use_native, result_kind): | |
| from pypaimon.table.data_evolution_merge_into import WhenMatched | |
| table = _merge_table(tmp_path, use_native) | |
| source = pa.table({'id': pa.array([1, 2], pa.int32())}) | |
| calls = [] | |
| def values(batch): | |
| calls.append(batch.num_rows) | |
| if result_kind == 'chunked': | |
| return pa.chunked_array([pa.array([101]), pa.array([202])]) | |
| if result_kind == 'wrong_length': | |
| return pa.array([1]) | |
| return [101, 202] | |
| update = table.new_batch_write_builder().new_update() | |
| if result_kind == 'chunked': | |
| messages = update.merge_into(source, on=['id'], when_matched=[WhenMatched.update({'value': values})]) | |
| table.new_batch_write_builder().new_commit().commit(messages) | |
| assert [row['value'] for row in _rows(table)] == [101, 202] | |
| else: | |
| with pytest.raises((ValueError, TypeError)): | |
| update.merge_into(source, on=['id'], when_matched=[WhenMatched.update({'value': values})]) | |
| assert [row['value'] for row in _rows(table)] == [10, 20] | |
| assert calls == [2] | |
| def test_merge_callback_aliases_use_coalesced_key_type(): | |
| from pypaimon.ray.data_evolution_merge_transform import _merge_callable_input | |
| dictionary = pa.array([1, 2], pa.int32()).dictionary_encode() | |
| batch = pa.table({'t.id': pa.array([1, 2], pa.int32()), 's.source_id': dictionary, | |
| 's.value': [10, 20], '_scratch': [0, 0]}) | |
| actual = _merge_callable_input(batch, [('source_id', 'id')]) | |
| assert actual.column_names == ['s.source_id', 's.value', 't.id'] | |
| assert actual['s.source_id'].equals(actual['t.id']) | |
| # INSERT callbacks retain source layout because no target key exists. | |
| unmatched = _merge_callable_input(batch.drop(['t.id']), [('source_id', 'id')]) | |
| assert unmatched['s.source_id'].type == dictionary.type | |
| def test_explicit_callable_read_columns_keep_self_merge_projection_narrow(): | |
| from pypaimon.ray.data_evolution_merge_into import _resolve_target_projection | |
| from pypaimon.ray.data_evolution_merge_transform import _NormalizedClause | |
| clauses = [_NormalizedClause({'value': lambda rows: rows['value']})] | |
| fields = ['id', 'value', 'large_blob'] | |
| assert _resolve_target_projection(clauses, ['_ROW_ID'], ['value'], fields, | |
| callable_columns=['value']) == ['value'] | |
| assert _resolve_target_projection(clauses, ['id'], ['value'], fields) == fields | |
| def test_consumed_blob_rows_are_released_without_losing_unplanned_inputs(): | |
| from pypaimon.schema.data_types import AtomicType | |
| from pypaimon.table.row.blob import Blob, BlobDescriptor | |
| from pypaimon.execution.native.write.native_blob_rows import NativeBlobRows | |
| rows = NativeBlobRows() | |
| blob_type = AtomicType('BLOB') | |
| known = BlobDescriptor.deserialize(rows.encode(Blob.from_data(b'known'), blob_type)).uri | |
| unknown = BlobDescriptor.deserialize(rows.encode(Blob.from_data(b'unknown'), blob_type)).uri | |
| reader = rows.create(known) | |
| assert rows.create(known) is reader | |
| rows.release_consumed() | |
| assert not rows._supports_uri(known) | |
| assert rows._supports_uri(unknown) | |
| with rows.create(unknown).new_input_stream(unknown) as stream: | |
| assert stream.read() == b'unknown' | |
| rows.clear() | |
| assert not rows.readers | |
| assert not rows._consumed | |
| @pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)]) | |
| def test_automatic_postpone_plan_counts_all_batches_per_partition(tmp_path, use_native): | |
| from pypaimon.write.write_dispatch import NativePostponeFixedBucketTableWrite | |
| schema = pa.schema([('id', pa.int64()), ('pt', pa.string())]) | |
| catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'postpone')}) | |
| catalog.create_database('default', True) | |
| catalog.create_table('default.t', Schema.from_pyarrow_schema( | |
| schema, primary_keys=['id', 'pt'], partition_keys=['pt'], options={ | |
| 'bucket': '-2', 'write.native.enabled': str(use_native).lower(), | |
| 'postpone.target-row-num-per-bucket': '2', | |
| 'postpone.batch-write-fixed-bucket.max-parallelism': '3'}), False) | |
| table = catalog.get_table('default.t') | |
| builder = table.new_postpone_fixed_bucket_write_builder() | |
| writer = builder.new_write() | |
| if use_native: | |
| assert isinstance(writer, NativePostponeFixedBucketTableWrite) | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist([{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}], schema=schema)) | |
| writer.write_arrow(pa.Table.from_pylist([{'id': n, 'pt': 'a'} for n in [3, 4, 5, 6]], schema=schema)) | |
| messages = writer.prepare_commit() | |
| assert {m.partition: m.total_buckets for m in messages} == {('a',): 3, ('b',): 1} | |
| builder.new_commit().commit(messages) | |
| finally: | |
| writer.close() | |
| assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}] + [ | |
| {'id': n, 'pt': 'a'} for n in [3, 4, 5, 6]] | |
| @native | |
| @pytest.mark.parametrize('kind', ['boolean', 'date', 'time', 'timestamp', 'decimal', 'float', | |
| 'unicode_int', 'decimal_overflow']) | |
| def test_native_truncate_partition_specs_use_java_typed_strings(tmp_path, native_rest_catalog, kind): | |
| from datetime import date, datetime, time | |
| from decimal import Decimal | |
| cases = { | |
| 'boolean': (pa.bool_(), [True, False], 'TRUE'), | |
| 'date': (pa.date32(), [date(2024, 1, 2), date(2024, 1, 3)], '2024-01-02'), | |
| 'time': (pa.time32('ms'), [time(12, 34, 56, 789000), time(12, 35)], '12:34:56.789'), | |
| 'timestamp': (pa.timestamp('us'), [datetime(2024, 1, 2, 12, 34), datetime(2024, 1, 3)], | |
| '2024-01-02 12:34:00'), | |
| 'decimal': (pa.decimal128(6, 2), [Decimal('12.35'), Decimal('13.00')], '12.345'), | |
| 'float': (pa.float32(), [1.2, 2.0], '1.2'), | |
| 'unicode_int': (pa.int32(), [12, 13], '12'), | |
| 'decimal_overflow': (pa.decimal128(3, 0), [None, Decimal('12')], '1000'), | |
| } | |
| data_type, values, text = cases[kind] | |
| schema = pa.schema([('id', pa.int64()), ('pt', data_type)]) | |
| native_rest_catalog.create_table('default.typed', Schema.from_pyarrow_schema( | |
| schema, partition_keys=['pt'], options={ | |
| 'write.native.enabled': 'false', 'commit.native.enabled': 'true'}), False) | |
| table = native_rest_catalog.get_table('default.typed') | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| writer.write_arrow(pa.table({'id': [1, 2], 'pt': values}, schema=schema)) | |
| commit.commit(writer.prepare_commit()) | |
| finally: | |
| writer.close() | |
| commit.close() | |
| commit = table.new_batch_write_builder().new_commit() | |
| try: | |
| with patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.drop_partitions', | |
| side_effect=AssertionError('Python truncate')): | |
| commit.truncate_partitions([{'pt': text}]) | |
| assert [row['id'] for row in _rows(table)] == [2] | |
| finally: | |
| commit.close() | |
| @pytest.mark.python_plan | |
| @pytest.mark.python_read | |
| @pytest.mark.parametrize('fail', [False, True]) | |
| def test_postpone_blob_row_bridge_keeps_sources_until_prepare(fail): | |
| from types import SimpleNamespace | |
| from pypaimon.common.options.core_options import CoreOptions | |
| from pypaimon.common.options.options import Options | |
| from pypaimon.schema.data_types import AtomicType, DataField | |
| from pypaimon.table.row.blob import Blob, BlobDescriptor | |
| from pypaimon.table.row.generic_row import GenericRow | |
| from pypaimon.write.write_dispatch import NativePostponeFixedBucketTableWrite | |
| # Exercise the bridge's deferred input lifetime without creating a table: | |
| # Python catalog validation does not allow a primary-key BLOB schema. | |
| fields = [DataField(0, 'id', AtomicType('INT')), DataField(1, 'pt', AtomicType('STRING')), | |
| DataField(2, 'payload', AtomicType('BLOB'))] | |
| table = SimpleNamespace( | |
| table_schema=SimpleNamespace(fields=fields, primary_keys=['id', 'pt']), fields=fields, | |
| field_dict={field.name: field for field in fields}, partition_keys=['pt'], | |
| is_primary_key_table=True, cross_partition_update=False, | |
| options=CoreOptions(Options({'bucket': '-2', 'postpone.target-row-num-per-bucket': '1'}))) | |
| backend = Mock() | |
| staged, payloads = [], [] | |
| backend.write_arrow.side_effect = lambda batch, bucket: staged.append(batch) | |
| writer = NativePostponeFixedBucketTableWrite(table, 'job', None, None, backend) | |
| factory = backend.with_blob_uri_reader_factory.call_args[0][0] | |
| error = RuntimeError('prepare failed') | |
| def prepare(): | |
| for batch in staged: | |
| for encoded in batch.column('payload').to_pylist(): | |
| uri = BlobDescriptor.deserialize(encoded).uri | |
| with factory.create(uri).new_input_stream(uri) as stream: | |
| payloads.append(stream.read()) | |
| if fail: | |
| raise error | |
| return [] | |
| backend.prepare_commit.side_effect = prepare | |
| try: | |
| for identifier in [1, 2]: | |
| writer.write_row(GenericRow( | |
| [identifier, 'new', Blob.from_data(str(identifier).encode())], fields)) | |
| assert len(factory.readers) == 2 | |
| assert not payloads | |
| if fail: | |
| with pytest.raises(RuntimeError) as raised: | |
| writer.prepare_commit() | |
| assert raised.value is error | |
| else: | |
| assert writer.prepare_commit() == [] | |
| assert payloads == [b'1', b'2'] | |
| assert not factory.readers | |
| backend.abort.assert_not_called() | |
| finally: | |
| writer.close() |