blob: c6790089237e7d3fe977a0a2fbf5fdc325e03496 [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.
"""Blob Arrow writes must retain row identity across physical file rolls."""
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 BlobDescriptor
from pypaimon.write.write_dispatch import NativeTableWrite
from pypaimon.tests.native_blob_write_test import _read, _physical_files
pytestmark = pytest.mark.native_plan
def _table(tmp_path, native, external=False, optimize=True, catalog=None, partitioned=False, not_null=False):
if catalog is None:
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('db', True)
blob = AtomicType('BLOB', nullable=not not_null)
fields = [DataField(0, 'id', AtomicType('INT')),
DataField(1, 'items', ArrayType(True, blob)),
DataField(2, 'attrs', MapType(True, AtomicType('STRING'), blob))]
options = {'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',
'data-evolution.write-cols-optimization.enabled': str(optimize).lower()}
if external:
options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
schema = Schema(fields=fields, options=options, partition_keys=['id'] if partitioned else [])
catalog.create_table('db.t', schema, False)
return catalog.get_table('db.t')
def _data(table, tmp_path, descriptor, start):
payload = b'payload' * 10
if descriptor:
source = tmp_path / 'payload'
source.write_bytes(b'prefix' + payload)
value = BlobDescriptor(str(source), 6, -1).serialize()
else:
value = payload
rows = [{'id': -1, 'items': [b'ignored'], 'attrs': [('ignored', b'ignored')]},
{'id': start, 'items': [value, None, b''], 'attrs': [('first', value), ('', b''), ('null', None)]},
{'id': start + 1, 'items': None, 'attrs': None},
{'id': start + 2, 'items': [], 'attrs': []}]
data = pa.Table.from_pylist(rows, schema=PyarrowFieldParser.from_paimon_schema(table.fields)).slice(1)
expected = [{'id': start, 'items': [payload, None, b''],
'attrs': [('first', payload), ('', b''), ('null', None)]}, rows[2], rows[3]]
return data, expected
@pytest.mark.parametrize('native_write', [False, True])
@pytest.mark.parametrize('external', [False, True])
@pytest.mark.parametrize('descriptor', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_collection_blob_write_cross_reads_and_rolls(tmp_path, native_write, external, descriptor, stream):
table = _table(tmp_path, native_write, external)
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 isinstance(writer, NativeTableWrite) == native_write
for identifier in range(1, 3 if stream else 2):
data, rows = _data(table, tmp_path, descriptor, identifier * 3)
writer.write_arrow(data)
expected.extend(rows)
messages = writer.prepare_commit(identifier) if stream else writer.prepare_commit()
files = [file for message in messages for file in message.new_files]
for name in ('items', 'attrs'):
dedicated = [file for file in files if file.write_cols == [name]]
assert dedicated and all(file.file_name.endswith('.blob') for file in dedicated)
assert sum(file.row_count for file in dedicated) == 3
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_write:
assert writer._python_writer is None
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('native_commit', [False, True])
@pytest.mark.parametrize('optimize', [False, True])
def test_collection_blob_descriptors_are_payload_ranges(tmp_path, native_rest_catalog, native_commit, optimize):
table = _table(tmp_path, True, optimize=optimize, catalog=native_rest_catalog).copy(
{'commit.native.enabled': str(native_commit).lower()})
data, expected = _data(table, tmp_path, False, 1)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
assert isinstance(writer, NativeTableWrite)
writer.write_arrow(data)
messages = writer.prepare_commit()
normal = [file for message in messages for file in message.new_files if file.file_name.endswith('.parquet')]
assert all(file.write_cols == (None if optimize else ['id']) for file in normal)
commit.commit(messages)
assert (commit._native_commit is not None) == native_commit
for reader in (False, True):
rows = _read(table.copy({'blob-as-descriptor': 'true'}), True, reader)
assert rows[1]['items'] is None and rows[2]['items'] == []
assert rows[1]['attrs'] is None and rows[2]['attrs'] == []
for descriptors, values in ((rows[0]['items'], expected[0]['items']),
([item for _, item in rows[0]['attrs']],
[item for _, item in expected[0]['attrs']])):
for encoded, payload in zip(descriptors, values):
if payload is None:
assert encoded is None
else:
descriptor = BlobDescriptor.deserialize(encoded)
assert descriptor.uri.endswith('.blob') and descriptor.length == len(payload)
from urllib.parse import urlparse
with open(urlparse(descriptor.uri).path, 'rb') as source:
source.seek(descriptor.offset)
assert source.read(descriptor.length) == payload
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('action', ['close', 'abort', 'failed-write', 'short-read'])
def test_collection_blob_failed_writes_and_abort_clean_all_groups(tmp_path, action):
table = _table(tmp_path, True)
writer = table.new_batch_write_builder().new_write()
try:
assert isinstance(writer, NativeTableWrite)
data, _ = _data(table, tmp_path, False, 1)
writer.write_arrow(data)
assert {path.suffix for path in _physical_files(tmp_path)} == {'.parquet', '.blob'}
if action in ('failed-write', 'short-read'):
if action == 'short-read':
source = tmp_path / 'truncated'
source.write_bytes(b'x' * (8 * 1024 * 1024))
missing = BlobDescriptor(str(source), 0, 8 * 1024 * 1024 + 1).serialize()
else:
missing = BlobDescriptor(str(tmp_path / 'missing'), 0, 10).serialize()
data = pa.table({'id': [5], 'items': [[b'already-copied', missing]],
'attrs': [[('x', b'ok')]]}, schema=data.schema)
with pytest.raises(Exception):
writer.write_arrow(data)
else:
getattr(writer, action)()
assert not _physical_files(tmp_path)
assert table.snapshot_manager().get_latest_snapshot() is None
finally:
writer.close()
@pytest.mark.parametrize('native_write', [False, True])
@pytest.mark.parametrize('action', ['abort', 'failed-write'])
def test_stream_collection_failure_keeps_already_committed_groups(tmp_path, native_write, action):
table = _table(tmp_path, native_write)
builder = table.new_stream_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
data, expected = _data(table, tmp_path, False, 1)
writer.write_arrow(data)
messages = writer.prepare_commit(1)
committed = {file.file_name for message in messages for file in message.new_files}
commit.commit(messages, 1)
assert writer.prepare_commit(2) == []
if action == 'failed-write':
missing = BlobDescriptor(str(tmp_path / 'missing'), 0, 1).serialize()
with pytest.raises(Exception):
writer.write_arrow(pa.table({'id': [10], 'items': [[b'ok', missing]], 'attrs': [[]]},
schema=data.schema))
else:
writer.write_arrow(data)
writer.abort()
assert committed <= {path.name for path in _physical_files(tmp_path)}
for native_read in (False, True):
assert _read(table, True, native_read) == expected
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('native_write', [False, True])
@pytest.mark.parametrize('native_commit', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('published', [False, True])
def test_collection_commit_exception_preserves_files(
tmp_path, native_rest_catalog, native_write, native_commit, stream, published):
from unittest.mock import Mock, patch
table = _table(tmp_path, native_write, external=True, catalog=native_rest_catalog).copy(
{'commit.native.enabled': str(native_commit).lower()})
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 isinstance(writer, NativeTableWrite) == native_write
if stream:
data, rows = _data(table, tmp_path, True, 1)
writer.write_arrow(data)
commit.commit(writer.prepare_commit(1), 1)
expected.extend(rows)
data, rows = _data(table, tmp_path, True, 4)
writer.write_arrow(data)
messages = writer.prepare_commit(2) if stream else writer.prepare_commit()
files = _physical_files(tmp_path)
assert {path.suffix for path in files} >= {'.parquet', '.blob'}
if native_commit:
prepared = commit._prepare_native_commit(messages)
assert prepared is not None
native, native_messages = prepared
proxy = Mock(wraps=native)
publish = native.commit
else:
publish = commit.file_store_commit.commit
def fail_commit(*args, **kwargs):
if published:
publish(*args, **kwargs)
raise OSError('Commit outcome is unknown')
if native_commit:
proxy.commit.side_effect = fail_commit
failing_commit = patch.object(
commit, '_prepare_native_commit', return_value=(proxy, native_messages))
else:
failing_commit = patch.object(commit.file_store_commit, 'commit', side_effect=fail_commit)
with failing_commit, \
patch.object(commit.file_store_commit, 'abort', side_effect=AssertionError('Unsafe abort')):
with pytest.raises(OSError, match='Commit outcome is unknown'):
commit.commit(messages, 2) if stream else commit.commit(messages)
if native_commit:
proxy.commit.assert_called_once()
proxy.abort.assert_not_called()
# An unknown commit outcome must never trigger explicit commit abort.
writer.abort()
writer.close()
assert _physical_files(tmp_path) == files
assert (tmp_path / 'payload').exists()
if published:
expected.extend(rows)
latest = table.snapshot_manager().get_latest_snapshot()
assert (latest.id if latest is not None else 0) == int(stream) + int(published)
for native_read in (False, True):
read_builder = table.copy({'read.native.enabled': str(native_read).lower()}).new_read_builder()
actual = read_builder.new_read().to_arrow(read_builder.new_scan().plan().splits())
assert actual.sort_by('id').to_pylist() == expected
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('projection', [
['id', "attrs['first']", "attrs['null']", "attrs['missing']"],
{'payload': "attrs['first']", 'array': 'items'},
['attrs', "attrs['first']", 'id'],
])
def test_native_collection_projection_keeps_selected_blob_keys(tmp_path, projection):
from unittest.mock import patch
table = _table(tmp_path, True)
data, _ = _data(table, tmp_path, False, 1)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(data)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
for native in (False, True):
read_table = table.copy({'read.native.enabled': str(native).lower()})
read_builder = read_table.new_read_builder().with_projection(projection)
read = read_builder.new_read()
splits = read_builder.new_scan().plan().splits()
if native:
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python fallback')
):
result = read.to_arrow(splits)
else:
expected = read.to_arrow(splits)
continue
assert result.schema == expected.schema
assert result.to_pylist() == expected.to_pylist()
def test_collection_partial_partition_prepare_keeps_cleanup_ownership(tmp_path):
from unittest.mock import patch
from pypaimon.execution.python.write.writer.composite_data_writer import CompositeDataWriter
table = _table(tmp_path, False, partitioned=True)
writer = table.new_batch_write_builder().new_write()
try:
data, _ = _data(table, tmp_path, False, 1)
writer.write_arrow(data)
partitions = list(writer.file_store_write.data_writers.values())
assert len(partitions) == 3 and all(isinstance(partition, CompositeDataWriter) for partition in partitions)
with patch.object(partitions[1], 'prepare_commit', side_effect=RuntimeError('Second partition failed')):
with pytest.raises(RuntimeError, match='Second partition failed'):
writer.prepare_commit()
assert partitions[0].committed_files
assert _physical_files(tmp_path)
writer.abort()
assert not _physical_files(tmp_path)
assert table.snapshot_manager().get_latest_snapshot() is None
finally:
writer.close()
@pytest.mark.parametrize('native_write', [False, True])
@pytest.mark.parametrize('sliced', [False, True])
@pytest.mark.parametrize('aliases', [False, True])
def test_not_null_collection_children_ignore_masked_and_unreferenced_values(
tmp_path, native_write, sliced, aliases):
table = _table(tmp_path, native_write, not_null=True)
offsets = pa.array([0, 1, 2, 3, 4], type=pa.int32())
mask = pa.array([True, False, True, False])
values = pa.array([None, b'first', None, b'last'], type=pa.large_binary())
items = pa.ListArray.from_arrays(
offsets, values, mask=mask,
type=pa.list_(pa.field('item' if aliases else 'element', pa.large_binary(), nullable=False)))
attrs = pa.MapArray.from_arrays(
offsets, pa.array(['hidden', 'first', 'hidden', 'last']), values, mask=mask,
type=pa.map_(pa.field('source_key' if aliases else 'key', pa.string(), nullable=False),
pa.field('source_value' if aliases else 'value', pa.large_binary(), nullable=False)))
data = pa.table({'id': pa.array(range(4), type=pa.int32()), 'items': items, 'attrs': attrs})
if sliced:
data = data.slice(1, 1)
data.validate(full=True)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
assert isinstance(writer, NativeTableWrite) == native_write
writer.write_arrow(data)
commit.commit(writer.prepare_commit())
for native_read in (False, True):
assert _read(table, True, native_read) == data.to_pylist()
finally:
writer.close()
commit.close()