blob: 2495a88291da0f022b704bea350ced14f04d9ccd [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 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()