blob: 212c6fb66b989da14e823e3efbfa2e086efeeb94 [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 explain reports ordinary planning work without disabling early pruning."""
from contextlib import ExitStack
import json
import pickle
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.execution.native.table import (_native_table)
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.tests import native_plan_rest_test, native_query_auth_test
from pypaimon.utils.file_store_path_factory import canonical_data_file_path
rest_catalog = native_plan_rest_test.rest_catalog
guarded_table = native_query_auth_test.guarded_table
pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='Rust planner required')]
def _create(catalog, schema=None, partition_keys=None, primary_keys=None, options=None):
schema = schema or pa.schema([('id', pa.int32()), ('value', pa.int32())])
settings = {'bucket': '-1', 'source.split.target-size': '1b',
'source.split.open-file-cost': '1b', 'scan.native-plan.enabled': 'true',
'read.native.enabled': 'false', 'write.native.enabled': 'false',
'commit.native.enabled': 'false', 'metadata.stats-mode': 'full'}
settings.update(options or {})
catalog.create_table('default.scan_stats', Schema.from_pyarrow_schema(
schema, partition_keys=partition_keys or [], primary_keys=primary_keys or [], options=settings), False)
return catalog.get_table('default.scan_stats'), schema
def _append(table, rows, schema, overwrite=False):
builder = table.new_batch_write_builder()
if overwrite:
builder.overwrite()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
messages = writer.prepare_commit()
commit.commit(messages)
return messages
finally:
writer.close()
commit.close()
def _native_explain(builder, verbose=False):
with patch('pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan_with_stats',
side_effect=AssertionError('explain fell back to Python')):
result = builder.explain(verbose)
assert result.native_planned
assert 'pruning not tracked' not in str(result)
return result
@pytest.mark.parametrize('limit,expected_files', [(None, 2), (0, 0), (1, 2)])
def test_native_explain_reports_actual_file_statistics_work(rest_catalog, limit, expected_files):
catalog, _ = rest_catalog
table, schema = _create(catalog)
for start in [0, 10, 100]:
_append(table, [{'id': start + i, 'value': start + i} for i in range(3)], schema)
builder = table.new_read_builder()
builder.with_filter(builder.new_predicate_builder().less_or_equal('value', 20))
if limit is not None:
builder.with_limit(limit)
result = _native_explain(builder, True)
assert result.snapshot_id == 3
assert result.file_count == expected_files
assert result.file_skipping is None
assert result.planning_counters['manifest_entries_read'] == (0 if limit == 0 else 3)
assert result.planning_counters['manifest_entries_pruned_by_data_stats'] == (0 if limit == 0 else 1)
assert 'Manifest entries decoded' in str(result)
assert json.loads(json.dumps(result.planning_counters)) == result.planning_counters
assert result.split_count == len(builder.new_scan().plan().splits())
assert result.splits is not None
def test_native_explain_reports_actual_bucket_then_partition_pruning(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': i, 'value': i} for pt in [1, 2] for i in range(64)], schema)
def explain(native):
selected = table.copy({'scan.native-plan.enabled': str(native).lower()}).new_read_builder()
pb = selected.new_predicate_builder()
selected.with_filter(pb.and_predicates([pb.equal('pt', 1), pb.equal('id', 7)]))
return _native_explain(selected) if native else selected.explain()
expected, actual = explain(False), explain(True)
for name in ['partition_pruning', 'bucket_pruning', 'file_skipping']:
assert getattr(actual, name) is None
assert actual.planning_counters['manifest_entries_read'] == 8
# A partition-specialized bucket selector conservatively keeps the other
# partition, whose four entries are rejected by the partition filter.
assert actual.planning_counters['manifest_entries_pruned_by_bucket'] == 3
assert actual.planning_counters['manifest_entries_pruned_by_partition'] == 4
assert actual.file_count == expected.file_count == 1
@pytest.mark.parametrize('limit,expected_files', [(None, 3), (0, 0), (1, 1)])
def test_native_explain_preserves_limit_stopping_without_row_filter(rest_catalog, limit, expected_files):
catalog, _ = rest_catalog
table, schema = _create(catalog)
for start in range(3):
_append(table, [{'id': start, 'value': start}], schema)
reader = table.new_read_builder()
if limit is not None:
reader.with_limit(limit)
scan = reader.new_scan()
with patch.object(scan.file_scanner, 'scan_with_stats', side_effect=AssertionError('Python stats ran')):
plan, stats = scan.scan_with_stats()
assert stats.entries_after_stats is None
assert stats.planning_counters['final_files'] == expected_files
assert pickle.loads(pickle.dumps(stats)) == stats
if limit is not None:
assert stats.planning_counters['limit_early_stopped']
assert stats.planning_counters['split_candidates_built'] == limit
assert len(plan.splits()) == expected_files
assert [split.file_paths for split in plan.splits()] == [
split.file_paths for split in reader.new_scan().plan().splits()]
@pytest.mark.parametrize('selection', ['whole_shard', 'partial_shard', 'whole_slice', 'partial_slice'])
@pytest.mark.parametrize('limit', [1, 2])
def test_native_statistics_follow_distributed_limit(rest_catalog, selection, limit):
catalog, _ = rest_catalog
table, schema = _create(catalog)
for value in range(3):
_append(table, [{'id': value, 'value': value}], schema)
reader = table.new_read_builder().with_limit(limit)
scan = reader.new_scan()
if selection.endswith('shard'):
scan.with_shard(0, 1 if selection == 'whole_shard' else 2)
else:
scan.with_slice(0 if selection == 'whole_slice' else 1, 3)
with patch('pypaimon.read.scan_stats.ScanStats.from_native',
side_effect=AssertionError('planning eagerly converted Native statistics')):
plan = scan.plan()
stats = plan.stats()
assert stats.native_planned
counters = stats.planning_counters
assert counters['manifest_entries_read'] == counters['split_candidates_built'] == 3
assert counters['splits_before_limit'] == 3
assert counters['final_splits'] == counters['splits_after_limit'] == len(plan.splits()) == limit
assert counters['final_files'] == sum(len(split.files) for split in plan.splits()) == limit
assert counters['planned_data_file_bytes'] == sum(split.file_size for split in plan.splits())
assert counters['limit'] == limit
assert not counters['limit_early_stopped'] # Native built all candidates before Python applied LIMIT.
assert plan.stats() is stats
assert pickle.loads(pickle.dumps(plan)).stats() == stats
assert len(reader.new_read().to_arrow(plan.splits())) == limit
@pytest.mark.parametrize('window', ['future', 'equal'])
@pytest.mark.parametrize('limit', [None, 1])
def test_native_empty_incremental_window_has_zero_statistics(rest_catalog, window, limit):
catalog, _ = rest_catalog
table, schema = _create(catalog)
_append(table, [{'id': 0, 'value': 0}], schema)
timestamp = table.snapshot_manager().get_latest_snapshot().time_millis
bounds = (timestamp + 1000, timestamp + 2000) if window == 'future' else (timestamp, timestamp)
selected = table.copy({'incremental-between-timestamp': '{},{}'.format(*bounds)})
reader = selected.new_read_builder()
if limit is not None:
reader.with_limit(limit)
scan = reader.new_scan()
with patch.object(scan.file_scanner, 'scan_with_stats', side_effect=AssertionError('Python stats ran')):
plan, stats = scan.scan_with_stats()
assert plan.splits() == []
assert plan.snapshot_id is None
assert stats.native_planned
expected = plan.stats().planning_counters
reference = table._copy_with_snapshot(None).new_read_builder()
if limit is not None:
reference.with_limit(limit)
assert expected == reference.new_scan().plan().stats().planning_counters
assert expected['final_files'] == expected['manifest_entries_read'] == 0
assert expected['limit'] == limit
assert expected['limit_early_stopped'] is False
for name in ['entries_potential_total', 'entries_after_partition', 'entries_after_bucket',
'entries_after_stats', 'partition_keys_before', 'partition_keys_after',
'buckets_seen', 'buckets_after_pruning']:
assert getattr(stats, name) is None
explanation = _native_explain(reader)
assert explanation.planning_counters == expected
assert 'Native planning counters' in str(explanation)
assert pickle.loads(pickle.dumps(plan)).stats() == stats
def test_native_explain_records_cross_schema_overwrite_netting(rest_catalog):
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import SchemaChange
catalog, _ = rest_catalog
table, schema = _create(catalog)
_append(table, [{'id': 1, 'value': 1}], schema)
catalog.alter_table('default.scan_stats', [SchemaChange.add_column('extra', AtomicType('INT'))], False)
table = catalog.get_table('default.scan_stats')
schema = schema.append(pa.field('extra', pa.int32()))
_append(table, [{'id': 2, 'value': 100, 'extra': 3}], schema, overwrite=True)
outcomes = []
for native in [False, True]:
reader = table.copy({'scan.native-plan.enabled': str(native).lower()}).new_read_builder()
reader.with_filter(reader.new_predicate_builder().greater_than('value', 50))
outcomes.append(_native_explain(reader) if native else reader.explain())
expected, actual = outcomes
assert actual.file_count == expected.file_count == 1
assert actual.file_skipping is None
assert actual.planning_counters['manifest_entries_read'] == 3
assert actual.planning_counters['manifest_entries_after_merge'] == 1
def test_native_explain_keeps_empty_and_historical_snapshot_metadata(rest_catalog):
catalog, _ = rest_catalog
table, schema = _create(catalog)
empty = table._copy_with_snapshot(None)
result = _native_explain(empty.new_read_builder())
assert result.snapshot_id is None
assert result.file_count == 0
_append(table, [{'id': 1, 'value': 10}], schema)
first = table.snapshot_manager().get_latest_snapshot()
historical = table._copy_with_snapshot(first)
_append(table, [{'id': 2, 'value': 20}], schema)
result = _native_explain(historical.new_read_builder())
assert result.snapshot_id == 1
assert result.file_count == 1
assert _native_explain(empty.new_read_builder()).file_count == 0
builder = table.new_read_builder()
builder.with_filter(builder.new_predicate_builder().greater_than('value', 1000))
result = _native_explain(builder)
assert result.snapshot_id == 2
assert result.file_count == 0
assert result.file_skipping is None
assert result.planning_counters['manifest_entries_pruned_by_data_stats'] == 2
def test_native_explain_does_not_open_data_files(rest_catalog):
catalog, _ = rest_catalog
table, schema = _create(catalog)
messages = _append(table, [{'id': 1, 'value': 10}], schema)
with ExitStack() as cleanup:
for message in messages:
for file in message.new_files:
path = LocalFileIO()._to_file(canonical_data_file_path(
table, message.partition, message.bucket, file.file_name))
hidden = path.with_suffix('.unavailable')
path.rename(hidden)
cleanup.callback(hidden.rename, path)
result = _native_explain(table.new_read_builder())
assert result.file_count == 1
assert result.estimated_row_count == 1
def test_native_stats_are_readonly_and_normal_planning_stays_lightweight(rest_catalog):
catalog, _ = rest_catalog
table, schema = _create(catalog)
_append(table, [{'id': i, 'value': i} for i in range(3)], schema)
scan = _native_table(table).new_read_builder().new_scan()
plan = scan.plan()
stats = plan.stats()
assert stats.manifest_entries_read == stats.manifest_entries_after_manifest_filters == 1
assert plan.snapshot_id() == 1
with pytest.raises(AttributeError):
stats.manifest_entries_read = 100
with patch('pypaimon.read.scan_stats.ScanStats.from_native',
side_effect=AssertionError('ordinary plan requested statistics')):
ordinary = table.new_read_builder().new_scan().plan()
assert ordinary.snapshot_id == 1
converted = ordinary.stats()
assert converted.planning_counters['manifest_entries_read'] == 1
assert converted.planning_counters['final_files'] == 1
assert ordinary.stats() is converted
# An untouched Native handle must be converted during serialization too.
restored = pickle.loads(pickle.dumps(table.new_read_builder().new_scan().plan()))
assert restored.stats() == converted
@pytest.mark.parametrize('changelog', [False, True])
def test_native_incremental_explain_reports_only_the_selected_window(rest_catalog, changelog):
catalog, _ = rest_catalog
table, schema = _create(catalog, primary_keys=['id'] if changelog else None,
options={'bucket': '1', 'changelog-producer': 'input'} if changelog else {})
for index, timestamp in enumerate([1000, 2000, 3000]):
_append(table, [{'id': index, 'value': timestamp}], schema)
snapshot = table.snapshot_manager().get_latest_snapshot()
path = table.snapshot_manager().snapshot_dir + '/snapshot-' + str(snapshot.id)
with table.file_io.new_input_stream(path) as stream:
data = json.loads(stream.read())
data['timeMillis'] = timestamp
table.file_io.overwrite_file_utf8(path, json.dumps(data))
selected = table.copy({'incremental-between-timestamp': '1000,3000'})
result = _native_explain(selected.new_read_builder(), True)
assert result.snapshot_id == 3
assert result.file_count == 2
scan = selected.new_read_builder().new_scan()
with patch.object(scan.file_scanner, 'scan_with_stats', side_effect=AssertionError('Python stats ran')):
plan, stats = scan.scan_with_stats()
assert stats.native_planned
assert stats.planning_counters['manifest_entries_read'] == stats.planning_counters['final_files'] == 2
assert plan.snapshot_id == 3
assert all(split.is_streaming for split in plan.splits())
assert all(file.file_name.startswith('changelog-') == changelog
for split in plan.splits() for file in split.files)
@pytest.mark.parametrize('limit', [None, 0, 1])
def test_native_data_evolution_explain_tracks_projection_with_limit(rest_catalog, limit):
catalog, _ = rest_catalog
table, schema = _create(catalog, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'})
_append(table, [{'id': i, 'value': i} for i in range(3)], schema)
builder = table.new_batch_write_builder()
updater = builder.new_update().new_update_by_row_id()
messages = updater.update_columns(pa.table({'_ROW_ID': [0, 1, 2], 'value': pa.array([10, 11, 12], pa.int32())}),
['value'])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
reader = table.new_read_builder().with_projection(['id'])
if limit is not None:
reader.with_limit(limit)
scan = reader.new_scan()
with patch.object(scan.file_scanner, 'scan_with_stats', side_effect=AssertionError('Python stats ran')):
plan, stats = scan.scan_with_stats()
assert stats.native_planned
assert stats.entries_potential_total is None
assert stats.planning_counters['manifest_entries_read'] == (0 if limit == 0 else 2)
assert stats.planning_counters['final_files'] == (0 if limit == 0 else 1)
assert len(plan.splits()) == (0 if limit == 0 else 1)
def test_native_explain_preserves_the_authorized_plan_and_captured_policy(guarded_table):
import json
from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.common.predicate_builder import PredicateBuilder
table, server = guarded_table
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
filter=[native_query_auth_test._filter('id', 'GREATER_THAN', [1])],
column_masking={'value': json.dumps({
'name': 'SUBSTRING', 'inputs': [native_query_auth_test._field('source'), 1, 2]})})
table = table.copy({'scan.native-plan.enabled': 'true', 'read.native.enabled': 'true'})
reader = table.new_read_builder().with_projection(['id', 'value'])
reader.with_filter(PredicateBuilder(table.fields).equal('value', 'ab')).with_limit(1)
scan = reader.new_scan()
with patch.object(scan.file_scanner, 'scan_with_stats', side_effect=AssertionError('Python stats ran')):
plan, stats = scan.scan_with_stats()
assert stats.native_planned
assert plan.splits()
assert all(getattr(split, '_native_split', None) is not None for split in plan.splits())
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse()
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Native authorized read fell back')
):
assert reader.new_read().to_arrow(plan.splits()).to_pylist() == [{'id': 2, 'value': 'ab'}]