blob: c55397b6088b863e3e1c3f214acd33e4d4291c48 [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.
"""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()