blob: 0c49b8adb1016e6476627e2e0b7a1ddd88fd5d6c [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.
"""Java-compatible data directories across Python and Rust execution."""
from contextlib import ExitStack
import os
from pathlib import Path
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
from pypaimon.execution.native.read.plan import (native_plan)
from pypaimon.schema.data_types import AtomicType, DataField
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='append', directory='relative', native=True, partitioned=True, extra=None):
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('db', True)
directories = {'relative': 'data/nested', 'normalized': 'data//discard/../nested/.',
'absolute': str(tmp_path / 'relocated'), 'uri': (tmp_path / 'relocated').as_uri(),
'literal_uri': 'file:' + str(tmp_path / 'data%2Fwith space?#fragment')}
options = {'data-file.path-directory': directories[directory],
'write.native.enabled': str(native).lower()}
if mode == 'pk':
options.update({'bucket': '1', 'changelog-producer': 'input'})
if mode == 'evolution':
options.update({'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'target-file-row-num': '2',
'deletion-vectors.enabled': 'true'})
options.update(extra or {})
partitions = ['pt'] if partitioned else []
keys = ['id'] + partitions if mode == 'pk' else []
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:
if native:
assert isinstance(writer, NativeTableWrite)
writer.write_arrow(data)
messages = writer.prepare_commit(identifier) if streaming else writer.prepare_commit()
factory = table.path_factory()
for message in messages:
expected = factory.bucket_path(tuple(message.partition), message.bucket, canonical_partition=True)
for file in message.new_files + message.changelog_files:
assert file.file_path.startswith(expected + '/')
assert table.file_io.exists(file.file_path)
if streaming:
commit.commit(messages, identifier)
else:
commit.commit(messages)
if native:
assert writer._python_writer is None
finally:
writer.close()
commit.close()
def _read(table, native_scan, native_read, streaming=False, snapshot=None):
options = {'read.native.enabled': str(native_read).lower(),
'scan.native-plan.enabled': str(native_scan).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 native_scan else builder.new_scan().plan()
read = builder.new_read()
with ExitStack() as stack:
if native_read:
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read fallback')
))
if streaming:
reader = read.to_arrow_batch_reader(plan.splits())
try:
result = reader.read_all()
finally:
reader.close()
else:
result = read.to_arrow(plan.splits())
return result.sort_by('id').to_pylist()
@pytest.mark.parametrize('directory', [
'relative', 'normalized', 'absolute', 'uri',
pytest.param('literal_uri', marks=pytest.mark.skipif(os.name == 'nt', reason='POSIX file names'))])
@pytest.mark.parametrize('mode', ['append', 'pk', 'evolution'])
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('partitioned', [False, True])
def test_data_directory_interoperability(tmp_path, directory, mode, native, partitioned):
table = _table(tmp_path, mode, directory, native, partitioned)
data = pa.table({'id': [1, 2, 3], 'pt': ['a', 'a', 'b'], 'value': [10, 20, 30]}, schema=_SCHEMA)
_write(table, data, native)
_write(table, pa.table({'id': [4], 'pt': ['b'], 'value': [40]}, schema=_SCHEMA), native,
streaming=True, identifier=2)
if directory == 'literal_uri':
assert (tmp_path / 'data%2Fwith space?#fragment').is_dir()
assert not (tmp_path / 'data').exists()
expected = data.to_pylist() + [{'id': 4, 'pt': 'b', 'value': 40}]
for planner in (False, True):
for reader in (False, True):
assert _read(table, planner, reader, streaming=reader) == expected
assert (Path(table.table_path) / 'snapshot' / 'snapshot-2').is_file()
assert (Path(table.table_path) / 'schema' / 'schema-0').is_file()
assert not (Path(table.table_path) / 'bucket-0').exists()
@pytest.mark.skipif(os.name == 'nt', reason='POSIX file names')
@pytest.mark.parametrize('strategy', ['round-robin', 'entropy-inject', 'weight-robin'])
def test_literal_data_directory_with_external_paths(tmp_path, strategy):
table = _table(tmp_path, directory='literal_uri', native=False, extra={
'data-file.external-paths': ','.join((tmp_path / name).as_uri() for name in ['first', 'second']),
'data-file.external-paths.strategy': strategy,
'data-file.external-paths.weights': '1,2',
})
data = pa.table({'id': [1], 'pt': ['a'], 'value': [10]}, schema=_SCHEMA)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(data)
messages = writer.prepare_commit()
assert all(file.external_path for message in messages for file in message.new_files)
commit.commit(messages)
finally:
writer.close()
commit.close()
assert (tmp_path / 'data%2Fwith space?#fragment').is_dir()
for planner in (False, True):
for reader in (False, True):
assert _read(table, planner, reader) == data.to_pylist()
@pytest.mark.parametrize('directory', [
'relative', 'normalized', 'absolute', 'uri',
pytest.param('literal_uri', marks=pytest.mark.skipif(os.name == 'nt', reason='POSIX file names'))])
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('bucket_indexes', [False, True])
def test_data_directory_updates_deletes_history_and_abort(tmp_path, directory, native, bucket_indexes):
table = _table(tmp_path, 'evolution', directory, native, extra={
'index-file-in-data-file-dir': str(bucket_indexes).lower()})
_write(table, pa.table({'id': [1, 2, 3], 'pt': ['a'] * 3, 'value': [10, 20, 30]}, schema=_SCHEMA), native)
builder = table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(PythonTableUpsert, '_upsert_partition',
side_effect=AssertionError('Python upsert fallback')))
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 sorted(row['value'] for row in _read(table, planner, reader)) == expected
messages = builder.new_update().delete_by_row_id([2])
staged = {entry.index_file.file_name for msg in messages for entry in msg.index_adds}
assert staged
before = {p for p in tmp_path.rglob('index-*') if p.name in staged}
assert before
commit = builder.new_commit()
try:
commit.abort(messages)
finally:
commit.close()
assert not any(path.exists() for path in before)
assert [row['value'] for row in _read(table, True, True, snapshot=1)] == [10, 20, 30]
assert [row['value'] for row in _read(table, True, True)] == [30, 40]
@pytest.mark.parametrize('directory', ['relative', 'absolute', 'uri'])
def test_data_directory_blob_native_read(tmp_path, directory):
table = _table(tmp_path, 'evolution', directory, False)
catalog = table.catalog_environment.catalog_loader.load()
catalog.drop_table('db.t')
fields = [DataField(0, 'id', AtomicType('INT')), DataField(1, 'payload', AtomicType('BLOB'))]
catalog.create_table('db.t', Schema(fields=fields, options=table.table_schema.options), False)
table = catalog.get_table('db.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': pa.array([1, 2, 3], pa.int32()),
'payload': pa.array([b'hello', None, b''], pa.large_binary())}))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
for planner in (False, True):
for reader in (False, True):
assert _read(table, planner, reader) == [
{'id': 1, 'payload': b'hello'}, {'id': 2, 'payload': None}, {'id': 3, 'payload': b''}]