blob: ff057faf84cfe6e0cb460f5b2804290a2b8dbbb9 [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.
"""Fixed-bucket writers honor Java's configured MOD routing."""
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.schema.table_schema import TableSchema
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.tests import native_plan_rest_test
from pypaimon.write.write_dispatch import NativeTableWrite
from pypaimon.execution.python.write.row_key_extractor import FixedBucketRowKeyExtractor
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
rest_catalog = native_plan_rest_test.rest_catalog
_ENGINES = [False, pytest.param(True, marks=[pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')])]
def _schema(data_type, options=None):
return Schema.from_pyarrow_schema(
pa.schema([('id', data_type), ('value', pa.string())]), primary_keys=['id'],
options={'bucket': '5', 'bucket-function.type': 'mod', **(options or {})})
def _extractor(schema):
return FixedBucketRowKeyExtractor(TableSchema(
fields=schema.fields, primary_keys=schema.primary_keys, options=schema.options))
@pytest.mark.parametrize('data_type,minimum,maximum', [
(pa.int32(), -(2 ** 31), 2 ** 31 - 1),
(pa.int64(), -(2 ** 63), 2 ** 63 - 1),
])
def test_mod_routes_rows_and_arrow_using_floor_mod(data_type, minimum, maximum):
extractor = _extractor(_schema(data_type))
values = [minimum, -17, -3, -1, 0, 5, 17, maximum, None]
expected = [0 if value is None else value % 5 for value in values]
assert [extractor.extract_partition_bucket_row({'id': value})[1] for value in values] == expected
batch = pa.record_batch([pa.array(values, data_type)], names=['id'])
assert extractor.extract_partition_bucket_batch(batch)[1] == expected
@pytest.mark.parametrize('data_type', [pa.int8(), pa.int16(), pa.float64(), pa.string()])
def test_mod_rejects_non_int_or_bigint_bucket_keys(data_type):
with pytest.raises(ValueError, match='bucket key type must be INT or BIGINT'):
_extractor(_schema(data_type))
def test_mod_requires_exactly_one_bucket_key():
schema = Schema.from_pyarrow_schema(
pa.schema([('id', pa.int32()), ('other', pa.int32())]),
primary_keys=['id', 'other'], options={'bucket': '5', 'bucket-function.type': 'mod'})
with pytest.raises(ValueError, match='bucket key must have exactly one field'):
_extractor(schema)
@pytest.mark.parametrize('bucket_function,error', [
('hive', NotImplementedError), ('unknown', ValueError), ('', ValueError),
])
def test_python_cannot_silently_route_an_unsupported_bucket_function(bucket_function, error):
with pytest.raises(error, match='[Bb]ucket function'):
extractor = _extractor(_schema(pa.int32(), {'bucket-function.type': bucket_function}))
extractor.extract_partition_bucket_row({'id': 17})
def test_none_bucket_function_retains_the_default_routing():
unset = _extractor(_schema(pa.int32(), {'bucket-function.type': None}))
default = _extractor(_schema(pa.int32(), {'bucket-function.type': 'default'}))
batch = pa.record_batch([pa.array([-3, 0, 17], pa.int32())], names=['id'])
assert unset.extract_partition_bucket_batch(batch) == default.extract_partition_bucket_batch(batch)
for value in [-3, 0, 17]:
assert unset.extract_partition_bucket_row({'id': value}) == default.extract_partition_bucket_row({'id': value})
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
def test_unset_bucket_function_allows_filtered_reads(rest_catalog, native):
catalog, _ = rest_catalog
catalog.create_table('default.t', _schema(pa.int32(), {
'bucket-function.type': None, 'write.native.enabled': str(native).lower(),
'commit.native.enabled': str(native).lower(), 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'}), 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({'id': pa.array([-3, 0, 17], pa.int32()), 'value': ['a', 'b', 'c']}))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
read = table.new_read_builder()
read.with_filter(read.new_predicate_builder().equal('id', 17))
assert read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist() == [{'id': 17, 'value': 'c'}]
def test_hive_single_bucket_retains_existing_row_and_arrow_routing():
extractor = _extractor(_schema(pa.int32(), {'bucket-function.type': 'hive', 'bucket': '1'}))
batch = pa.record_batch([pa.array([-3, 0, 17], pa.int32())], names=['id'])
assert extractor.extract_partition_bucket_batch(batch)[1] == [0, 0, 0]
for value in [-3, 0, 17]:
assert extractor.extract_partition_bucket_row({'id': value})[1] == 0
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
def test_hive_precomputed_bucket_does_not_calculate_hashes(rest_catalog, native):
catalog, _ = rest_catalog
catalog.create_table('default.t', _schema(pa.int32(), {
'bucket-function.type': 'hive', 'write.native.enabled': str(native).lower(),
'commit.native.enabled': str(native).lower(), 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'}), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
assert isinstance(writer, NativeTableWrite) == native
with ExitStack() as guard:
if native:
guard.enter_context(patch.object(writer, '_switch_to_python', side_effect=AssertionError('fallback')))
guard.enter_context(patch.object(
commit.file_store_commit, 'commit', side_effect=AssertionError('Python commit fallback')))
else:
guard.enter_context(patch.object(
writer.row_key_extractor, '_extract_buckets_batch',
side_effect=AssertionError('automatic routing')))
writer.write_arrow(pa.table({'id': pa.array([17], pa.int32()), 'value': ['explicit']}), bucket=3)
messages = writer.prepare_commit()
assert [message.bucket for message in messages] == [3]
commit.commit(messages)
finally:
writer.close()
commit.close()
read = table.new_read_builder()
assert read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist() == [{'id': 17, 'value': 'explicit'}]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('streaming', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('row_input', [False, True], ids=['arrow', 'row'])
@pytest.mark.parametrize('data_type,minimum,maximum', [
(pa.int32(), -(2 ** 31), 2 ** 31 - 1),
(pa.int64(), -(2 ** 63), 2 ** 63 - 1),
])
def test_mod_regular_and_precomputed_writes_share_buckets(
rest_catalog, native, streaming, row_input, data_type, minimum, maximum):
catalog, _ = rest_catalog
catalog.create_table('default.t', _schema(data_type, {
'write.native.enabled': str(native).lower(), 'commit.native.enabled': str(native).lower(),
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'}), False)
table = catalog.get_table('default.t')
builder = table.new_stream_write_builder() if streaming else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
values = [minimum, -17, -3, -1, 0, 5, 17, maximum]
arrow_schema = pa.schema([('id', data_type), ('value', pa.string())])
try:
assert isinstance(writer, NativeTableWrite) == native
with ExitStack() as guards:
if native:
guards.enter_context(patch.object(writer, '_switch_to_python', side_effect=AssertionError('fallback')))
guards.enter_context(patch.object(
commit.file_store_commit, 'commit', side_effect=AssertionError('Python commit fallback')))
if row_input:
for value in values:
writer.write_row(GenericRow([value, 'original'], table.fields))
else:
writer.write_arrow(pa.Table.from_pydict(
{'id': values, 'value': ['original'] * len(values)}, schema=arrow_schema))
# This upstream bucket must agree with normal assignment. Otherwise
# the same key is committed twice to different LSM bucket trees.
writer.write_arrow(pa.Table.from_pydict(
{'id': [-3], 'value': ['updated']}, schema=arrow_schema), bucket=2)
messages = writer.prepare_commit(7) if streaming else writer.prepare_commit()
commit.commit(messages, 7) if streaming else commit.commit(messages)
finally:
writer.close()
commit.close()
read_builder = table.new_read_builder()
reader = read_builder.new_read()
rows = []
for split in read_builder.new_scan().plan().splits():
split_rows = reader.to_arrow([split]).to_pylist()
assert all(row['id'] % 5 == split.bucket for row in split_rows)
rows.extend(split_rows)
assert sorted(rows, key=lambda row: row['id']) == [
{'id': value, 'value': 'updated' if value == -3 else 'original'} for value in sorted(values)]