blob: 3f74a3fb860f8bb17604887251d2a2b9a34f6635 [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.
import sys
import unittest
from types import ModuleType
from unittest.mock import Mock, patch
from pypaimon.catalog.catalog_context import CatalogContext
from pypaimon.catalog.filesystem_catalog_loader import FileSystemCatalogLoader
from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader
from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options import Options
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.globalindex.vector_search_result import ScoredGlobalIndexResult
from pypaimon.read.native_plan import (
_catalog_options,
_predicate_to_native,
_read_options,
_restore_python_partition_paths,
native_plan,
)
from pypaimon.read.scan_stats import ScanStats
from pypaimon.read.table_scan import TableScan
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.utils.range import Range
def _scan(native_enabled, file_scanner):
"""Build a TableScan without running its heavy __init__."""
scan = TableScan.__new__(TableScan)
scan.table = Mock()
scan.table.options.native_plan_enabled.return_value = native_enabled
scan.table.options.options.contains_key.return_value = False # no time-travel
scan.table.options.options.contains.return_value = False # no incremental
scan.table.options.merge_engine.return_value = None # not first-row
scan.table.options.query_auth_enabled = False
scan.table.current_branch.return_value = 'main'
scan.table.is_primary_key_table = False # not a pk table
scan.table.trimmed_primary_keys = ['k'] # non-empty trimmed pk
scan.table.bucket_mode.return_value = BucketMode.HASH_FIXED
scan.table._applied_dynamic_options = {} # no copy() overrides
scan.table.partition_keys = [] # not partitioned
scan.table.table_schema.id = 1
scan.table.schema_manager.latest.return_value.id = 1 # loaded schema is latest
scan.table.identifier.get_database_name.return_value = 'default'
scan.table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
CatalogContext.create_from_options(Options({}))) # filesystem catalog
file_scanner.idx_of_this_subtask = None # no shard
file_scanner.start_pos_of_this_subtask = None # no slice
file_scanner.chunk_shuffle = None # no chunk-shuffle
file_scanner._global_index_result = None # no global-index result
file_scanner._row_ranges = None # no explicit row ranges
file_scanner.deletion_vectors_enabled = False # no deletion vectors
file_scanner.data_evolution = False # no data evolution
file_scanner.only_read_real_buckets = False # not postpone bucket
scan.file_scanner = file_scanner
scan.predicate = None
scan.partition_predicate = None
scan._query_auth_fn = None # no query-auth restrictions
scan._read_type = None
scan.limit = None # no row limit
return scan
class NativePlanTest(unittest.TestCase):
def setUp(self):
# Make the real capability probe see a split-API-capable pypaimon-rust so
# gate tests route natively. Tests that call native_plan() directly override
# sys.modules within their own block.
fake_df = ModuleType('pypaimon_rust.datafusion')
fake_df.PaimonCatalog = type(
'PaimonCatalog', (), {'get_table': lambda self, name: None})
fake_df.Split = type('Split', (), {'serialize': lambda self: b''})
fake_mod = ModuleType('pypaimon_rust')
fake_mod.datafusion = fake_df
patcher = patch.dict(
sys.modules,
{'pypaimon_rust': fake_mod, 'pypaimon_rust.datafusion': fake_df})
patcher.start()
self.addCleanup(patcher.stop)
def test_switch_defaults_off(self):
self.assertFalse(CoreOptions(Options({})).native_plan_enabled())
self.assertTrue(
CoreOptions(Options({"scan.native-plan.enabled": "true"})).native_plan_enabled())
def test_plan_uses_file_scanner_when_switch_off(self):
fs = Mock()
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=False, file_scanner=fs)
self.assertIs(scan.plan(), sentinel)
fs.scan.assert_called_once_with()
def test_plan_routes_to_native_and_prunes_partitions(self):
# Native planner returns every partition; the predicate keeps only [2026, 7].
keep = Mock(partition=Mock(values=[2026, 7]))
drop = Mock(partition=Mock(values=[2025, 1]))
pred = Mock()
pred.test.side_effect = lambda part: part.values == [2026, 7]
fs = Mock(partition_key_predicate=pred)
scan = _scan(native_enabled=True, file_scanner=fs)
with patch('pypaimon.read.native_plan.native_plan', return_value=[keep, drop]) as np:
plan = scan.plan()
np.assert_called_once_with(
scan.table, predicate=None, limit=None, projection=None,
row_ranges=None)
fs.scan.assert_not_called()
self.assertEqual(plan.splits(), [keep])
def test_plan_falls_back_when_partition_prune_raises(self):
pred = Mock()
pred.test.side_effect = RuntimeError('predicate boom')
fs = Mock(partition_key_predicate=pred)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
split = Mock(partition=Mock(values=[2026, 7]), snapshot_id=1)
with patch('pypaimon.read.native_plan.native_plan', return_value=[split]):
self.assertIs(scan.plan(), sentinel)
fs.scan.assert_called_once_with()
def test_plan_native_no_partition_predicate_keeps_all(self):
splits = [Mock(partition=Mock(values=[1])), Mock(partition=Mock(values=[2]))]
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
with patch('pypaimon.read.native_plan.native_plan', return_value=splits):
self.assertEqual(scan.plan().splits(), splits)
def test_plan_forwards_filter_limit_partition_and_time_travel(self):
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
scan.table.partition_keys = ['dt']
scan.limit = 5
scan._read_type = [Mock(name='k'), Mock(name='dt')]
scan._read_type[0].name = 'k'
scan._read_type[1].name = 'dt'
scan.predicate = PredicateBuilder.and_predicates([
Predicate('equal', 0, 'k', [7]),
Predicate('equal', 1, 'dt', ['2026-08-02']),
])
scan.table._applied_dynamic_options = {'scan.snapshot-id': '3'}
scan.table.options.options.contains_key.side_effect = (
lambda key: key == 'scan.snapshot-id')
scan.table.table_schema.id = 2
scan.table.schema_manager.latest.return_value.id = 3
split = Mock(partition=Mock(values=['2026-08-02']), snapshot_id=3)
with patch('pypaimon.read.native_plan.native_plan', return_value=[split]) as np:
plan = scan.plan()
self.assertEqual(plan.snapshot_id, 3)
np.assert_called_once_with(
scan.table,
predicate=scan.predicate,
limit=5,
projection=['k', 'dt'],
row_ranges=None,
)
def test_plan_forwards_global_index_row_ranges(self):
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
fs.data_evolution = True
fs._global_index_result = GlobalIndexResult.from_ranges([
Range(1, 2), Range(5, 5)])
split = Mock(partition=Mock(values=[]), snapshot_id=3)
with patch('pypaimon.read.native_plan.native_plan', return_value=[split]) as np:
plan = scan.plan()
np.assert_called_once_with(
scan.table,
predicate=None,
limit=None,
projection=None,
row_ranges=[(1, 2), (5, 5)],
)
fs.scan.assert_not_called()
self.assertEqual(plan.splits(), [split])
def test_empty_global_index_result_does_not_fall_back(self):
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
fs.data_evolution = True
fs._global_index_result = GlobalIndexResult.create_empty()
with patch('pypaimon.read.native_plan.native_plan', return_value=[]) as np:
plan = scan.plan()
np.assert_called_once_with(
scan.table,
predicate=None,
limit=None,
projection=None,
row_ranges=[],
)
fs.scan.assert_not_called()
self.assertEqual(plan.splits(), [])
def test_scored_global_index_result_falls_back(self):
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
fs.data_evolution = True
bitmap = GlobalIndexResult.from_range(Range(1, 1)).results()
fs._global_index_result = ScoredGlobalIndexResult.create(
bitmap, lambda _: 1.0)
with patch('pypaimon.read.native_plan.native_plan') as np:
self.assertIs(scan.plan(), sentinel)
np.assert_not_called()
fs.scan.assert_called_once_with()
def test_global_index_row_ranges_require_data_evolution_append_table(self):
result = GlobalIndexResult.from_range(Range(1, 1))
for data_evolution, primary_key in ((False, False), (True, True)):
with self.subTest(
data_evolution=data_evolution, primary_key=primary_key):
fs = Mock(partition_key_predicate=None)
fs.scan.return_value = fallback = object()
scan = _scan(native_enabled=True, file_scanner=fs)
fs.data_evolution = data_evolution
fs._global_index_result = result
scan.table.is_primary_key_table = primary_key
with patch('pypaimon.read.native_plan.native_plan') as np:
self.assertIs(scan.plan(), fallback)
np.assert_not_called()
fs.scan.assert_called_once_with()
def test_plan_falls_back_when_scan_is_not_plain(self):
# Native planning does not carry shard/slice, explicit row ranges,
# arbitrary global-index results, or incremental scans.
def check(setup):
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
setup(scan, fs)
with patch('pypaimon.read.native_plan.native_plan') as np:
self.assertIs(scan.plan(), sentinel)
np.assert_not_called()
fs.scan.assert_called_once_with()
check(lambda s, fs: setattr(fs, 'idx_of_this_subtask', 0))
check(lambda s, fs: setattr(fs, 'start_pos_of_this_subtask', 0))
check(lambda s, fs: setattr(fs, 'chunk_shuffle', (1, 100)))
check(lambda s, fs: setattr(fs, '_global_index_result', object()))
check(lambda s, fs: setattr(fs, '_row_ranges', [object()]))
check(lambda s, fs: setattr(fs, 'deletion_vectors_enabled', True))
check(lambda s, fs: setattr(fs, 'only_read_real_buckets', True))
check(lambda s, fs: (setattr(s.table, 'is_primary_key_table', True),
setattr(s.table, 'trimmed_primary_keys', [])))
check(lambda s, fs: s.table.bucket_mode.__setattr__(
'return_value', BucketMode.HASH_DYNAMIC))
check(lambda s, fs: s.table.bucket_mode.__setattr__(
'return_value', BucketMode.CROSS_PARTITION))
check(lambda s, fs: setattr(
s.table, '_applied_dynamic_options', {'scan.snapshot-id': None}))
check(lambda s, fs: setattr(s.table.schema_manager.latest.return_value, 'id', 2))
check(lambda s, fs: s.table.schema_manager.latest.__setattr__(
'side_effect', RuntimeError('metadata read failed')))
check(lambda s, fs: s.table.options.options.contains_key.__setattr__(
'side_effect', lambda k: k == 'scan.version'))
check(lambda s, fs: s.table.options.merge_engine.__setattr__(
'return_value', 'first-row'))
check(lambda s, fs: setattr(s.table.options, 'query_auth_enabled', True))
check(lambda s, fs: s.table.current_branch.__setattr__('return_value', 'b1'))
check(lambda s, fs: s.table.identifier.get_database_name.__setattr__(
'return_value', 'db.name'))
check(lambda s, fs: s.table.identifier.get_database_name.__setattr__(
'return_value', 'unknown'))
check(lambda s, fs: setattr(
s.table.catalog_environment, 'catalog_loader', object())) # no context()
for attr in ('hadoop_conf', 'prefer_io_loader', 'fallback_io_loader'):
check(lambda s, fs, attr=attr: setattr(
s.table.catalog_environment.catalog_loader.context(), attr, object()))
check(lambda s, fs: s.table.options.options.contains.__setattr__(
'return_value', True)) # incremental
def test_plan_native_empty_falls_back(self):
# Empty native result -> fall back for an atomic snapshot id.
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
with patch('pypaimon.read.native_plan.native_plan', return_value=[]):
self.assertIs(scan.plan(), sentinel)
fs.scan.assert_called_once_with()
def test_plan_falls_back_when_rust_unavailable(self):
# scan.native-plan.enabled but pypaimon-rust missing/old -> fall back,
# not crash.
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
with patch('pypaimon.read.native_plan.native_runtime_available',
return_value=False), \
patch('pypaimon.read.native_plan.native_plan') as np:
self.assertIs(scan.plan(), sentinel)
np.assert_not_called()
fs.scan.assert_called_once_with()
def test_plan_falls_back_when_native_plan_raises(self):
# A native planning failure (e.g. unsupported scheme) must fall back, not crash.
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
with patch('pypaimon.read.native_plan.native_plan',
side_effect=RuntimeError('unsupported scheme viewfs://')):
self.assertIs(scan.plan(), sentinel)
fs.scan.assert_called_once_with()
def test_plan_falls_back_for_jdbc_catalog_loader(self):
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
scan.table.catalog_environment.catalog_loader = JdbcCatalogLoader(
CatalogContext.create_from_options(Options({})))
with patch('pypaimon.read.native_plan.native_plan') as np:
self.assertIs(scan.plan(), sentinel)
np.assert_not_called()
fs.scan.assert_called_once_with()
def test_plan_falls_back_for_builtin_catalog_loader_subclasses(self):
class RoutedFileSystemLoader(FileSystemCatalogLoader):
def load(self):
return object()
class RoutedRESTLoader(RESTCatalogLoader):
def load(self):
return object()
for loader_class in (RoutedFileSystemLoader, RoutedRESTLoader):
with self.subTest(loader_class=loader_class.__name__):
fs = Mock(partition_key_predicate=None)
sentinel = object()
fs.scan.return_value = sentinel
scan = _scan(native_enabled=True, file_scanner=fs)
scan.table.catalog_environment.catalog_loader = loader_class(
CatalogContext.create_from_options(Options({})))
with patch('pypaimon.read.native_plan.native_plan') as np:
self.assertIs(scan.plan(), sentinel)
np.assert_not_called()
fs.scan.assert_called_once_with()
def test_scan_with_stats_native_empty_uses_fallback_stats(self):
fs = Mock(partition_key_predicate=None)
fallback_plan = object()
fallback_stats = ScanStats(manifest_files_total=7)
fs.scan_with_stats.return_value = (fallback_plan, fallback_stats)
scan = _scan(native_enabled=True, file_scanner=fs)
with patch('pypaimon.read.native_plan.native_plan', return_value=[]) as np:
plan, stats = scan.scan_with_stats()
self.assertIs(plan, fallback_plan)
self.assertIs(stats, fallback_stats)
np.assert_called_once_with(
scan.table, predicate=None, limit=None, projection=None,
row_ranges=None)
fs.scan_with_stats.assert_called_once_with()
fs.scan.assert_not_called()
def test_catalog_options_are_normalized_for_rust(self):
table = Mock()
table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
CatalogContext.create_from_options(Options({
'warehouse': '/tmp/warehouse',
'data-token.enabled': True,
'retry-count': 3,
'unset': None,
})))
self.assertEqual(_catalog_options(table), {
'warehouse': '/tmp/warehouse',
# Lowercase so Rust's case-sensitive bool parser accepts it.
'data-token.enabled': 'true',
'retry-count': '3',
'metastore': 'filesystem',
})
def test_catalog_options_use_actual_rest_loader_type(self):
table = Mock()
table.catalog_environment.catalog_loader = RESTCatalogLoader(
CatalogContext.create_from_options(Options({
'uri': 'http://localhost:8181',
})))
self.assertEqual(_catalog_options(table), {
'uri': 'http://localhost:8181',
'metastore': 'rest',
})
def test_catalog_options_reject_loader_subclass(self):
class RoutedFileSystemLoader(FileSystemCatalogLoader):
pass
table = Mock()
table.catalog_environment.catalog_loader = RoutedFileSystemLoader(
CatalogContext.create_from_options(Options({})))
with self.assertRaisesRegex(ValueError, 'exact built-in catalog loader'):
_catalog_options(table)
def test_predicate_and_time_travel_are_converted_for_rust(self):
predicate = PredicateBuilder.and_predicates([
Predicate('greaterOrEqual', 0, 'k', [10]),
Predicate('in', 1, 'v', ['a', 'b']),
])
self.assertEqual(_predicate_to_native(predicate), {
'method': 'and',
'children': [
{'method': 'greaterOrEqual', 'field': 'k', 'literals': [10]},
{'method': 'in', 'field': 'v', 'literals': ['a', 'b']},
],
})
table = Mock()
table.options.source_split_target_size.return_value = 1024
table.options.source_split_open_file_cost.return_value = 128
table.options.options = Options({
'scan.snapshot-id': '9',
})
self.assertEqual(_read_options(table), {
'source.split.target-size': '1024',
'source.split.open-file-cost': '128',
'scan.snapshot-id': '9',
})
def test_partition_path_prefers_existing_python_legacy_path(self):
table = Mock(partition_keys=['p'])
table.path_factory.return_value.bucket_path.return_value = (
'/warehouse/t/p=a/b/bucket-0')
table.file_io.list_status.return_value = [Mock(base_name='data.parquet')]
data_file = Mock(
external_path=None,
file_name='data.parquet',
file_path='/warehouse/t/p=a%2Fb/bucket-0/data.parquet',
)
split = Mock(
partition=Mock(values=['a/b']), bucket=0, files=[data_file])
_restore_python_partition_paths(table, [split])
self.assertEqual(
data_file.file_path,
'/warehouse/t/p=a/b/bucket-0/data.parquet',
)
def test_partition_path_keeps_existing_rust_path(self):
table = Mock(partition_keys=['p'])
table.path_factory.return_value.bucket_path.return_value = (
'/warehouse/t/p=a/b/bucket-0')
table.file_io.list_status.return_value = []
rust_path = '/warehouse/t/p=a%2Fb/bucket-0/data.parquet'
data_file = Mock(
external_path=None,
file_name='data.parquet',
file_path=rust_path,
)
split = Mock(
partition=Mock(values=['a/b']), bucket=0, files=[data_file])
_restore_python_partition_paths(table, [split])
self.assertEqual(data_file.file_path, rust_path)
def test_partition_path_lists_each_bucket_once(self):
table = Mock(partition_keys=['p'])
table.path_factory.return_value.bucket_path.return_value = (
'/warehouse/t/p=a/b/bucket-0')
table.file_io.list_status.return_value = [
Mock(base_name='a.parquet'), Mock(base_name='b.parquet')]
splits = [
Mock(partition=Mock(values=['a/b']), bucket=0, files=[Mock(
external_path=None,
file_name=name,
file_path='/warehouse/t/p=a%%2Fb/bucket-0/%s' % name,
)])
for name in ('a.parquet', 'b.parquet')
]
_restore_python_partition_paths(table, splits)
table.file_io.list_status.assert_called_once_with(
'/warehouse/t/p=a/b/bucket-0')
self.assertEqual(
[split.files[0].file_path for split in splits],
[
'/warehouse/t/p=a/b/bucket-0/a.parquet',
'/warehouse/t/p=a/b/bucket-0/b.parquet',
],
)
def test_partition_path_listing_failure_is_not_hidden(self):
table = Mock(partition_keys=['p'])
table.path_factory.return_value.bucket_path.return_value = (
'/warehouse/t/p=a/b/bucket-0')
table.file_io.list_status.side_effect = PermissionError('denied')
split = Mock(partition=Mock(values=['a/b']), bucket=0, files=[Mock(
external_path=None,
file_name='data.parquet',
file_path='/warehouse/t/p=a%2Fb/bucket-0/data.parquet',
)])
with self.assertRaises(PermissionError):
_restore_python_partition_paths(table, [split])
def test_native_plan_threads_trimmed_keys_to_deserializer(self):
# PK tables route through: the trimmed primary keys must reach the
# deserializer so per-file min/max keys are decoded for merge-on-read.
kfields = [object()]
table = Mock(trimmed_primary_keys_fields=kfields)
table.table_schema = Mock(fields=[], partition_keys=[])
table.partition_keys = []
table.options.source_split_target_size.return_value = 1024
table.options.source_split_open_file_cost.return_value = 128
table.options.options.contains_key.return_value = False
split = Mock()
split.serialize.return_value = b'bytes'
rt = Mock()
builder = rt.new_read_builder.return_value
builder.with_row_ranges.return_value = builder
builder.new_scan.return_value.plan.return_value.splits.return_value = [split]
catalog = Mock()
catalog.get_table.return_value = rt
fake_df = ModuleType('pypaimon_rust.datafusion')
fake_df.PaimonCatalog = Mock(return_value=catalog)
fake_df.Split = type('Split', (), {'serialize': lambda self: b''})
fake_mod = ModuleType('pypaimon_rust')
fake_mod.datafusion = fake_df
with patch.dict(sys.modules,
{'pypaimon_rust': fake_mod, 'pypaimon_rust.datafusion': fake_df}), \
patch('pypaimon.read.native_plan._catalog_options', return_value={}), \
patch('pypaimon.read.native_plan.deserialize_split_v1',
return_value='decoded') as des:
result = native_plan(table, row_ranges=[(1, 2)])
self.assertEqual(result, ['decoded'])
rt.new_read_builder.assert_called_once_with({
CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(): '1024',
CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(): '128',
})
builder.with_row_ranges.assert_called_once_with([(1, 2)])
des.assert_called_once_with(b'bytes', [], kfields)
def test_native_plan_requires_split_api(self):
# An intermediate pypaimon-rust missing either get_table or Split.serialize
# must raise a clear error, not an AttributeError mid-plan.
split_ok = type('Split', (), {'serialize': lambda self: b''})
catalog_ok = type('PaimonCatalog', (), {'get_table': lambda self, name: None})
cases = {
'no get_table': (type('PaimonCatalog', (), {}), split_ok),
'no serialize': (catalog_ok, type('Split', (), {})),
}
for label, (catalog_cls, split_cls) in cases.items():
with self.subTest(case=label):
fake_df = ModuleType('pypaimon_rust.datafusion')
fake_df.PaimonCatalog = catalog_cls
fake_df.Split = split_cls
fake_mod = ModuleType('pypaimon_rust')
fake_mod.datafusion = fake_df
with patch.dict(
sys.modules,
{'pypaimon_rust': fake_mod, 'pypaimon_rust.datafusion': fake_df}):
with self.assertRaisesRegex(RuntimeError, '0.3.0'):
native_plan(Mock())
if __name__ == '__main__':
unittest.main()