blob: 447c51cbc5da123639f6d810feac10803f91bb9c [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 BucketSelector semantics through REST, normal and explain planning."""
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.tests.native_scan_stats_test import _append, _create
from pypaimon.tests import native_plan_rest_test
rest_catalog = native_plan_rest_test.rest_catalog
pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='Rust planner required')]
def _selected(table, predicate, native, stats=True, partition_filter=None, limit=None):
reader = table.copy({'scan.native-plan.enabled': str(native).lower(),
'read.native.enabled': str(native).lower()}).new_read_builder()
reader.with_filter(predicate)
if limit is not None:
reader.with_limit(limit)
if partition_filter is not None:
reader.with_partition_filter(partition_filter)
scan = reader.new_scan()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Native scan fell back')))
stack.enter_context(patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan_with_stats',
side_effect=AssertionError('Native statistics fell back')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Native read fell back')
))
if stats:
plan, counters = scan.scan_with_stats()
assert plan.stats() is counters
else:
plan, counters = scan.plan(), None
rows = reader.new_read().to_arrow(plan.splits()).to_pylist()
files = sorted((tuple(split.partition.values), split.bucket,
tuple(file.file_name for file in split.files)) for split in plan.splits())
return files, sorted(rows, key=lambda row: tuple(row[name] for name in sorted(row))), counters
def _predicate(pb, case):
equal = lambda name, value: pb.equal(name, value)
and_ = pb.and_predicates
or_ = pb.or_predicates
if case == 'or':
return or_([equal('id', 2), equal('id', 3)])
if case == 'intersection':
return and_([pb.is_in('id', [1, 2, 3]), pb.is_in('id', [2, 3, 4])])
if case == 'partition-or':
return or_([and_([equal('pt', 1), equal('id', 3)]),
and_([equal('pt', 2), equal('id', 7)])])
if case == 'unconstrained-branch':
return or_([and_([equal('pt', 1), equal('id', 3)]), equal('pt', 2)])
if case == 'unsafe-or':
return or_([equal('id', 3), equal('value', 0)])
if case == 'safe-and':
return and_([equal('id', 3), or_([equal('id', 3), equal('value', 0)])])
if case == 'contradiction':
return and_([equal('id', 2), equal('id', 3)])
raise AssertionError(case)
@pytest.mark.parametrize('primary_key', [False, True], ids=['append', 'pk'])
@pytest.mark.parametrize('case', ['or', 'intersection', 'partition-or', 'unconstrained-branch',
'unsafe-or', 'safe-and', 'contradiction'])
def test_native_bucket_selection_matches_java_compatible_python(rest_catalog, primary_key, case):
catalog, _ = rest_catalog
schema = pa.schema([('pt', pa.int32()), ('id', pa.int32()), ('value', pa.int32())])
table, _ = _create(catalog, schema, ['pt'], ['pt', 'id'] if primary_key else None,
{'bucket': '4', 'bucket-key': 'id', 'manifest.sidecar.enabled': 'true'})
rows = [{'pt': pt, 'id': index, 'value': index} for pt in [1, 2] for index in range(64)]
_append(table, rows, schema)
predicate = _predicate(table.new_read_builder().new_predicate_builder(), case)
files, expected, python = _selected(table, predicate, False)
actual_files, actual, native = _selected(table, predicate, True)
assert actual == expected
assert actual_files == files
assert native.entries_potential_total is None
assert native.entries_total <= python.entries_total
assert native.planning_counters['manifest_entries_after_entry_pruning'] == python.entries_after_bucket
assert native.planning_counters['manifest_entries_after_manifest_filters'] == python.entries_after_stats
assert _selected(table, predicate, True, stats=False)[:2] == (actual_files, actual)
if case == 'partition-or':
assert native.planning_counters['manifest_entries_after_entry_pruning'] == 2
elif case == 'unconstrained-branch':
assert native.planning_counters['manifest_entries_after_entry_pruning'] == 5
elif case in ('unsafe-or', 'contradiction'):
assert native.planning_counters['manifest_entries_after_entry_pruning'] == 8
else:
assert native.planning_counters['manifest_entries_after_entry_pruning'] < 8
@pytest.mark.parametrize('case', ['one-key-or', 'intersection', 'mixed-or'])
def test_native_composite_bucket_keys_preserve_java_clause_boundaries(rest_catalog, case):
catalog, _ = rest_catalog
schema = pa.schema([('pt', pa.int32()), ('a', pa.int32()), ('b', pa.int32()), ('value', pa.int32())])
table, _ = _create(catalog, schema, ['pt'], ['pt', 'a', 'b'], {'bucket': '8', 'bucket-key': 'a,b'})
_append(table, [{'pt': pt, 'a': a, 'b': b, 'value': a + b}
for pt in [1, 2] for a in range(16) for b in range(16)], schema)
pb = table.new_read_builder().new_predicate_builder()
if case == 'one-key-or':
predicate = pb.and_predicates([pb.or_predicates([pb.equal('a', 1), pb.equal('a', 2)]), pb.equal('b', 3)])
elif case == 'intersection':
predicate = pb.and_predicates([pb.is_in('a', [1, 2, 3]), pb.is_in('a', [2, 3, 4]), pb.equal('b', 3)])
else:
predicate = pb.or_predicates([pb.equal('a', 1), pb.equal('b', 3)])
files, expected, python = _selected(table, predicate, False)
actual_files, actual, native = _selected(table, predicate, True)
assert (actual_files, actual) == (files, expected)
assert native.planning_counters['manifest_entries_after_entry_pruning'] == python.entries_after_bucket
assert native.planning_counters['manifest_entries_after_manifest_filters'] == python.entries_after_stats
if case == 'mixed-or':
assert native.planning_counters['manifest_entries_after_entry_pruning'] == 16
else:
assert native.planning_counters['manifest_entries_after_entry_pruning'] <= 4
def test_explicit_partition_filter_does_not_replace_partition_values_in_bucket_predicate(rest_catalog):
catalog, _ = rest_catalog
schema = pa.schema([('pt', pa.int32()), ('id', pa.int32()), ('value', pa.int32())])
table, _ = _create(catalog, schema, ['pt'], ['pt', 'id'], {'bucket': '4', 'bucket-key': 'id'})
_append(table, [{'pt': pt, 'id': index, 'value': index} for pt in [1, 2] for index in range(64)], schema)
pb = table.new_read_builder().new_predicate_builder()
predicate = _predicate(pb, 'partition-or')
selector = pb.equal('pt', 2)
files, expected, python = _selected(table, predicate, False, partition_filter=selector)
actual_files, actual, native = _selected(table, predicate, True, partition_filter=selector)
assert (actual_files, actual) == (files, expected)
assert actual == [{'pt': 2, 'id': 7, 'value': 7}]
assert native.planning_counters['manifest_entries_after_entry_pruning'] == python.entries_after_bucket == 1
@pytest.mark.parametrize('masked', ['pt', 'value'])
def test_native_bucket_specialization_excludes_masked_inputs_and_keeps_safe_conjuncts(rest_catalog, masked):
import json
from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.schema.schema_change import SchemaChange
catalog, server = rest_catalog
schema = pa.schema([('pt', pa.string()), ('id', pa.int32()), ('value', pa.string())])
table, _ = _create(catalog, schema, ['pt'], ['pt', 'id'], {'bucket': '4', 'bucket-key': 'id'})
_append(table, [{'pt': pt, 'id': index, 'value': str(index)}
for pt in ['a', 'b'] for index in range(64)], schema)
catalog.alter_table(table.identifier, [SchemaChange.set_option('query-auth.enabled', 'true')])
table = catalog.get_table(table.identifier)
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
column_masking={masked: json.dumps({'name': 'CONCAT', 'inputs': ['visible']})})
pb = table.new_read_builder().new_predicate_builder()
if masked == 'pt':
# Raw partition 'a' would select id=7, losing the matching id=3 after masking.
predicate = pb.or_predicates([
pb.and_predicates([pb.equal('pt', 'a'), pb.equal('id', 7)]),
pb.and_predicates([pb.equal('pt', 'visible'), pb.equal('id', 3)]),
])
else:
predicate = pb.and_predicates([pb.equal('id', 3), pb.equal('value', 'visible')])
files, expected, python = _selected(table, predicate, False)
actual_files, actual, native = _selected(table, predicate, True)
assert (actual_files, actual) == (files, expected)
assert [row['id'] for row in actual] == [3, 3]
assert all(row[masked] == 'visible' for row in actual)
selected_entries = native.planning_counters['manifest_entries_after_entry_pruning']
assert selected_entries == python.entries_after_bucket == (8 if masked == 'pt' else 2)
assert _selected(table, predicate, True, stats=False)[:2] == (actual_files, actual)
@pytest.mark.parametrize('limit', [0, 1])
def test_native_correlated_bucket_statistics_preserve_limit(rest_catalog, limit):
catalog, _ = rest_catalog
schema = pa.schema([('pt', pa.int32()), ('id', pa.int32()), ('value', pa.int32())])
table, _ = _create(catalog, schema, ['pt'], ['pt', 'id'], {'bucket': '4', 'bucket-key': 'id'})
_append(table, [{'pt': pt, 'id': index, 'value': index} for pt in [1, 2] for index in range(64)], schema)
predicate = _predicate(table.new_read_builder().new_predicate_builder(), 'partition-or')
files, actual, stats = _selected(table, predicate, True, limit=limit)
assert stats.entries_potential_total is None
assert stats.planning_counters['manifest_entries_read'] == (0 if limit == 0 else 8)
assert stats.planning_counters['manifest_entries_after_entry_pruning'] == (0 if limit == 0 else 2)
assert len(actual) == limit
matches = [{'pt': 1, 'id': 3, 'value': 3}, {'pt': 2, 'id': 7, 'value': 7}]
assert all(row in matches for row in actual)
plain_files, plain_rows, _ = _selected(table, predicate, True, stats=False, limit=limit)
assert plain_files == files
assert len(plain_rows) == limit
# PK bucket scans have no row order, so LIMIT may select either matching row.
assert all(row in matches for row in plain_rows)
@pytest.mark.parametrize('changelog', [False, True])
def test_native_incremental_bucket_specialization_preserves_physical_events(rest_catalog, changelog):
import json
catalog, _ = rest_catalog
schema = pa.schema([('pt', pa.int32()), ('id', pa.int32()), ('value', pa.int32())])
options = {'bucket': '4', 'bucket-key': 'id'}
if changelog:
options['changelog-producer'] = 'input'
table, _ = _create(catalog, schema, ['pt'], ['pt', 'id'], options)
for timestamp in [1000, 2000]:
_append(table, [{'pt': pt, 'id': index, 'value': timestamp}
for pt in [1, 2] for index in range(64)], schema)
snapshot = table.snapshot_manager().get_latest_snapshot()
path = table.snapshot_manager().get_snapshot_path(snapshot.id)
metadata = json.loads(table.file_io.read_file_utf8(path))
metadata['timeMillis'] = timestamp
table.file_io.overwrite_file_utf8(path, json.dumps(metadata))
table = table.copy({'incremental-between-timestamp': '0,2000'})
predicate = _predicate(table.new_read_builder().new_predicate_builder(), 'partition-or')
files, expected, python = _selected(table, predicate, False)
actual_files, actual, native = _selected(table, predicate, True)
assert (actual_files, actual) == (files, expected)
assert len(actual) == 4
assert native.planning_counters['manifest_entries_after_entry_pruning'] == python.entries_after_bucket == 4
assert native.planning_counters['manifest_entries_after_manifest_filters'] == python.entries_after_stats
assert _selected(table, predicate, True, stats=False)[:2] == (actual_files, actual)