This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 4b275a4ef1 [python] Expand native Rust scan planning (#9000)
4b275a4ef1 is described below
commit 4b275a4ef1a6aae03d40f9638e293325f657181b
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Aug 4 11:38:29 2026 +0800
[python] Expand native Rust scan planning (#9000)
---
paimon-python/pypaimon/read/native_plan.py | 93 ++++++++++--
paimon-python/pypaimon/read/table_scan.py | 79 ++++++----
.../pypaimon/tests/native_plan_integration_test.py | 143 +++++++++++++++++-
paimon-python/pypaimon/tests/native_plan_test.py | 161 +++++++++++++++++++--
4 files changed, 423 insertions(+), 53 deletions(-)
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index 91f9827940..54c87e3594 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -18,8 +18,8 @@
"""Plan splits with pypaimon_rust, decoded for the normal pypaimon reader.
Optional, lazily-imported dependency; enabled by ``scan.native-plan.enabled``.
-The predicate is applied pypaimon-side (partition pruning + row/limit filter),
-so results match the normal path.
+Predicates and limits are pushed into Rust planning. The normal pypaimon reader
+still applies them while reading, so pushdown remains an optimization.
"""
from typing import List, Optional
@@ -27,6 +27,7 @@ from typing import List, Optional
from pypaimon.common.options.config import CatalogOptions
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options_utils import OptionsUtils
+from pypaimon.common.predicate import Predicate
from pypaimon.read.split import Split
from pypaimon.read.split_serializer import deserialize_split_v1
@@ -96,20 +97,85 @@ def _catalog_options(table) -> dict:
def _read_options(table) -> dict:
- """Effective split-shaping options, including FileStoreTable.copy
overrides."""
- return {
+ """Effective Rust read options, including FileStoreTable.copy overrides."""
+ options = {
CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(): str(
table.options.source_split_target_size()),
CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(): str(
table.options.source_split_open_file_cost()),
}
+ table_options = table.options.options
+ for option in (
+ CoreOptions.SCAN_SNAPSHOT_ID,
+ CoreOptions.SCAN_TAG_NAME,
+ CoreOptions.SCAN_TIMESTAMP_MILLIS):
+ if table_options.contains_key(option.key()):
+ options[option.key()] = _option_value_to_string(
+ table_options.get(option))
+
+ # Rust takes epoch millis but PyPaimon also accepts a timestamp string.
+ if table_options.contains_key(CoreOptions.SCAN_TIMESTAMP.key()):
+ from pypaimon.snapshot.time_travel_util import
_parse_timestamp_to_millis
+ options[CoreOptions.SCAN_TIMESTAMP_MILLIS.key()] = str(
+ _parse_timestamp_to_millis(
+ table_options.get(CoreOptions.SCAN_TIMESTAMP)))
+ return options
+
+
+def _predicate_to_native(predicate: Predicate) -> dict:
+ """Convert PyPaimon's predicate tree to pypaimon-rust's dict API."""
+ if predicate.method in ('and', 'or'):
+ children = predicate.literals or []
+ if not children:
+ raise ValueError("Native compound predicate requires children")
+ return {
+ 'method': predicate.method,
+ 'children': [_predicate_to_native(child) for child in children],
+ }
+ return {
+ 'method': predicate.method,
+ 'field': predicate.field,
+ 'literals': list(predicate.literals or []),
+ }
-def native_plan(table) -> List[Split]:
+def _restore_python_partition_paths(table, splits: List[Split]) -> None:
+ """Restore legacy PyPaimon paths with one listing per bucket."""
+ if not table.partition_keys:
+ return
+ path_factory = table.path_factory()
+ bucket_files = {}
+ for split in splits:
+ bucket_path = path_factory.bucket_path(
+ tuple(split.partition.values), split.bucket)
+ candidates = []
+ for data_file in split.files:
+ python_path = "%s/%s" % (
+ bucket_path.rstrip('/'), data_file.file_name)
+ if (not data_file.external_path
+ and python_path != data_file.file_path):
+ candidates.append((data_file, python_path))
+ if not candidates:
+ continue
+ if bucket_path not in bucket_files:
+ bucket_files[bucket_path] = {
+ status.base_name
+ for status in table.file_io.list_status(bucket_path)
+ }
+ for data_file, python_path in candidates:
+ if data_file.file_name in bucket_files[bucket_path]:
+ data_file.file_path = python_path
+
+
+def native_plan(
+ table,
+ predicate: Optional[Predicate] = None,
+ limit: Optional[int] = None,
+ projection: Optional[List[str]] = None) -> List[Split]:
"""Plan with pypaimon_rust and return the decoded pypaimon splits.
- Predicate/limit are not pushed to the native planner (pushdown is a
- follow-up); pypaimon applies them at read time.
+ Native conversion or planning failures are handled by TableScan, which
+ falls back to the Python planner.
"""
if not native_runtime_available():
raise RuntimeError(
@@ -117,8 +183,17 @@ def native_plan(table) -> List[Split]:
from pypaimon_rust.datafusion import PaimonCatalog
rt =
PaimonCatalog(_catalog_options(table)).get_table(table.identifier.get_full_name())
- rust_splits =
rt.new_read_builder(_read_options(table)).new_scan().plan().splits()
+ builder = rt.new_read_builder(_read_options(table))
+ if projection is not None:
+ builder = builder.with_projection(projection)
+ if predicate is not None:
+ builder = builder.with_filter(_predicate_to_native(predicate))
+ if limit is not None:
+ builder = builder.with_limit(limit)
+ rust_splits = builder.new_scan().plan().splits()
pfields = _partition_fields(table)
# Trimmed primary keys decode per-file min/max keys (PK merge-on-read).
kfields = table.trimmed_primary_keys_fields
- return [deserialize_split_v1(s.serialize(), pfields, kfields) for s in
rust_splits]
+ splits = [deserialize_split_v1(s.serialize(), pfields, kfields) for s in
rust_splits]
+ _restore_python_partition_paths(table, splits)
+ return splits
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index 313046aecb..410cae9a4e 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -37,6 +37,16 @@ _NATIVE_FORWARDED_OPTIONS = frozenset({
CoreOptions.SCAN_NATIVE_PLAN_ENABLED.key(),
CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(),
CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(),
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ CoreOptions.SCAN_TAG_NAME.key(),
+ CoreOptions.SCAN_TIMESTAMP.key(),
+ CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+})
+_NATIVE_TIME_TRAVEL_OPTIONS = frozenset({
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ CoreOptions.SCAN_TAG_NAME.key(),
+ CoreOptions.SCAN_TIMESTAMP.key(),
+ CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
})
@@ -86,31 +96,24 @@ class TableScan:
def _native_plan_supported_impl(self) -> bool:
"""Fall back to the Python scanner for scans native can't carry:
shard/slice, chunk-shuffle, global-index, first-row merge-engine (Rust
- drops L0), deletion vectors (Python drops L0), data evolution
- (dedicated split generator), postpone bucket (drops synthetic buckets),
+ drops L0), deletion vectors, postpone bucket (drops synthetic buckets),
a primary-key table whose trimmed PK is empty (PK equals the partition
key; native may mark splits raw-convertible and skip merge), dynamic
- bucket / cross-partition PK tables (unconfirmed Rust parity), any
- partitioned table (Rust bucket_path vs the writer's str(value) can
- diverge), a stale schema (Rust reloads the latest), copy() overrides
- Rust does not see (e.g. a removed scan.snapshot-id), a row limit
- (native has no plan-time limit pushdown), query auth, non-main branch,
- time-travel, scan.version, incremental, a missing/old pypaimon-rust, or
- a catalog / identifier Rust cannot reconstruct. Keep this capability
- gate in sync when adding scan features."""
+ bucket / cross-partition PK tables (unconfirmed Rust parity), a stale
+ schema without time travel, copy() overrides Rust does not see (notably
+ removing a persisted scan option), unsupported time travel selectors,
+ query auth, non-main branch, incremental scans, a missing/old
+ pypaimon-rust, or a catalog / identifier Rust cannot reconstruct. Keep
+ this capability gate in sync when adding scan features."""
from pypaimon.read.native_plan import native_runtime_available
if not native_runtime_available():
return False
- # Native has no plan-time limit pushdown; Python trims splits before
read.
- if self.limit is not None:
- return False
fs = self.file_scanner
if (getattr(fs, 'idx_of_this_subtask', None) is not None
or getattr(fs, 'start_pos_of_this_subtask', None) is not None
or getattr(fs, 'chunk_shuffle', None) is not None
or getattr(fs, '_global_index_result', None) is not None
or getattr(fs, 'deletion_vectors_enabled', False)
- or getattr(fs, 'data_evolution', False)
or getattr(fs, 'only_read_real_buckets', False)):
return False
loader = getattr(
@@ -146,20 +149,24 @@ class TableScan:
from pypaimon.table.bucket_mode import BucketMode
if self.table.bucket_mode() in (BucketMode.HASH_DYNAMIC,
BucketMode.CROSS_PARTITION):
return False
- # Rust bucket_path vs the writer's unescaped str(value) can diverge ->
fall back.
- if self.table.partition_keys:
- return False
- # Rust reloads the latest schema; fall back if this table's schema is
stale.
+ options = self.table.options.options
+ supported_time_travel = any(
+ options.contains_key(key) for key in _NATIVE_TIME_TRAVEL_OPTIONS)
+ # Time travel intentionally carries a historical schema; other stale
+ # table objects must still fall back because Rust reloads the latest.
latest_schema = self.table.schema_manager.latest()
- if latest_schema is not None and latest_schema.id !=
self.table.table_schema.id:
+ if (not supported_time_travel and latest_schema is not None
+ and latest_schema.id != self.table.table_schema.id):
return False
- # copy() overrides Rust can't see (e.g. removed scan.snapshot-id) ->
fall back.
- overrides = set(getattr(self.table, '_applied_dynamic_options', {}) or
{})
- if overrides - _NATIVE_FORWARDED_OPTIONS:
+ # Rust cannot remove an option persisted in the catalog-loaded schema.
+ applied_options = getattr(self.table, '_applied_dynamic_options', {})
or {}
+ if (set(applied_options) - _NATIVE_FORWARDED_OPTIONS
+ or any(key in _NATIVE_TIME_TRAVEL_OPTIONS and value is None
+ for key, value in applied_options.items())):
return False
from pypaimon.snapshot.time_travel_util import SCAN_KEYS
- options = self.table.options.options
- if any(options.contains_key(k) for k in SCAN_KEYS) \
+ unsupported_scan_keys = set(SCAN_KEYS) - _NATIVE_TIME_TRAVEL_OPTIONS
+ if any(options.contains_key(k) for k in unsupported_scan_keys) \
or options.contains_key('scan.version'):
return False
return not options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)
@@ -167,15 +174,29 @@ class TableScan:
def _try_native_plan(self) -> Optional[Plan]:
"""Plan via pypaimon_rust, then drop partitions the predicate rejects.
- The predicate is not pushed to the native planner, so this may read
more
- files; the reader's row filter/limit still apply, so results match.
Return
- None when Rust finds no splits so the caller can use the matching
Python
- fallback (with scan stats when requested).
+ Predicate and limit are pushed into Rust planning and are still
enforced
+ by the reader. Return None when Rust finds no splits so the caller can
use
+ the matching Python fallback (with scan stats when requested).
"""
from pypaimon.read.native_plan import native_plan
try:
- splits = native_plan(self.table)
+ native_predicate = self.predicate
+ if self.partition_predicate is not None:
+ native_predicate = PredicateBuilder.and_predicates([
+ predicate for predicate in (
+ native_predicate,
+ self.file_scanner.partition_key_predicate,
+ ) if predicate is not None
+ ])
+ splits = native_plan(
+ self.table,
+ predicate=native_predicate,
+ limit=self.limit,
+ projection=(
+ [field.name for field in self._read_type]
+ if self._read_type is not None else None),
+ )
if not splits:
return None
snapshot_id = splits[0].snapshot_id
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index a78e1b55a0..e308f3a611 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -120,6 +120,138 @@ class NativePlanIntegrationTest(unittest.TestCase):
self._write('ap_t', [{'k': 3, 'v': 'c'}])
self._assert_matches('ap_t')
+ def test_data_evolution_blob_projection_filter_limit(self):
+ schema = pa.schema([
+ ('k', pa.int64()),
+ ('v', pa.string()),
+ ('media.camera', pa.large_binary()),
+ ])
+ self.cat.create_table('default.de_t', Schema.from_pyarrow_schema(
+ schema, options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ }), False)
+ table = self.cat.get_table('default.de_t')
+ write_builder = table.new_batch_write_builder()
+ write = write_builder.new_write()
+ write.write_arrow(pa.Table.from_pylist([
+ {'k': 1, 'v': 'a', 'media.camera': b'a'},
+ {'k': 2, 'v': 'b', 'media.camera': b'b'},
+ {'k': 3, 'v': 'c', 'media.camera': b'c'},
+ ], schema=schema))
+ write_builder.new_commit().commit(write.prepare_commit())
+ write.close()
+
+ update_builder = table.new_batch_write_builder()
+ update = update_builder.new_update().with_update_type(['v'])
+ messages = update.update_by_arrow_with_row_id(pa.Table.from_pydict({
+ '_ROW_ID': pa.array([1], type=pa.int64()),
+ 'v': pa.array(['b2'], type=pa.string()),
+ }))
+ update_builder.new_commit().commit(messages)
+
+ self._assert_matches('de_t')
+
+ native_table = self.cat.get_table('default.de_t').copy(
+ {'scan.native-plan.enabled': 'true'})
+ predicate =
native_table.new_read_builder().new_predicate_builder().equal(
+ 'v', 'b2')
+ builder = (native_table.new_read_builder()
+ .with_projection(['k'])
+ .with_filter(predicate)
+ .with_limit(1))
+ plan = builder.new_scan().plan()
+ rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+
+ self.assertEqual(rows, [{'k': 2}])
+ self.assertTrue(builder.explain().native_planned)
+
+ blob_builder = (native_table.new_read_builder()
+ .with_projection(['media.camera'])
+ .with_limit(1))
+ blob_plan = blob_builder.new_scan().plan()
+ blob_rows = blob_builder.new_read().to_arrow(
+ blob_plan.splits()).to_pylist()
+ self.assertEqual(blob_rows, [{'media.camera': b'a'}])
+ self.assertTrue(any(
+ data_file.file_name.endswith('.blob')
+ for split in blob_plan.splits()
+ for data_file in split.files
+ ))
+
+ def test_filter_is_pushed_to_native_plan(self):
+ options = {
+ 'source.split.target-size': '1b',
+ 'source.split.open-file-cost': '1b',
+ }
+ self.cat.create_table('default.filter_t', Schema.from_pyarrow_schema(
+ self.schema, options=options), False)
+ for k in range(1, 4):
+ self._write('filter_t', [{'k': k, 'v': 'v%d' % k}])
+
+ table = self.cat.get_table('default.filter_t')
+ normal_builder = table.new_read_builder()
+ predicate = normal_builder.new_predicate_builder().equal('k', 2)
+ normal_builder.with_filter(predicate)
+ normal_plan = normal_builder.new_scan().plan()
+
+ native_builder = table.copy(
+ {'scan.native-plan.enabled': 'true'}).new_read_builder()
+ predicate = native_builder.new_predicate_builder().equal('k', 2)
+ native_builder.with_filter(predicate)
+ native_plan = native_builder.new_scan().plan()
+ rows =
native_builder.new_read().to_arrow(native_plan.splits()).to_pylist()
+
+ self.assertEqual(rows, [{'k': 2, 'v': 'v2'}])
+ self.assertEqual(len(native_plan.splits()), len(normal_plan.splits()))
+ self.assertTrue(native_builder.explain().native_planned)
+
+ def test_limit_is_pushed_to_native_plan(self):
+ options = {
+ 'source.split.target-size': '1b',
+ 'source.split.open-file-cost': '1b',
+ }
+ self.cat.create_table('default.limit_t', Schema.from_pyarrow_schema(
+ self.schema, options=options), False)
+ for k in range(1, 4):
+ self._write('limit_t', [{'k': k, 'v': 'v%d' % k}])
+
+ table = self.cat.get_table('default.limit_t')
+ normal = table.new_read_builder().with_limit(1).new_scan().plan()
+ native_builder = table.copy(
+ {'scan.native-plan.enabled':
'true'}).new_read_builder().with_limit(1)
+ native = native_builder.new_scan().plan()
+ rows = native_builder.new_read().to_arrow(native.splits()).to_pylist()
+
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(len(native.splits()), len(normal.splits()))
+ self.assertEqual(len(native.splits()), 1)
+ self.assertTrue(native_builder.explain().native_planned)
+
+ def test_snapshot_time_travel_matches_normal_plan(self):
+ self.cat.create_table(
+ 'default.travel_t', Schema.from_pyarrow_schema(self.schema), False)
+ self._write('travel_t', [{'k': 1, 'v': 'a'}])
+ self._write('travel_t', [{'k': 2, 'v': 'b'}])
+ options = {'scan.snapshot-id': '1'}
+
+ normal_table = self.cat.get_table('default.travel_t').copy(options)
+ normal_builder = normal_table.new_read_builder()
+ normal_plan = normal_builder.new_scan().plan()
+ normal_rows = normal_builder.new_read().to_arrow(
+ normal_plan.splits()).to_pylist()
+
+ native_table = normal_table.copy({'scan.native-plan.enabled': 'true'})
+ native_builder = native_table.new_read_builder()
+ native_plan = native_builder.new_scan().plan()
+ native_rows = native_builder.new_read().to_arrow(
+ native_plan.splits()).to_pylist()
+
+ self.assertEqual(native_plan.snapshot_id, 1)
+ self.assertEqual(native_rows, normal_rows)
+ self.assertEqual(native_rows, [{'k': 1, 'v': 'a'}])
+ self.assertTrue(native_builder.explain().native_planned)
+
def test_dynamic_split_target_size_matches_normal_plan(self):
self.cat.create_table(
'default.split_t', Schema.from_pyarrow_schema(self.schema), False)
@@ -182,8 +314,8 @@ class NativePlanIntegrationTest(unittest.TestCase):
self.assertEqual(native.split_count, len(normal.splits()))
self.assertEqual(native.split_count, 1)
- def test_partitioned_table_falls_back(self):
- # Rust bucket_path can diverge from the writer's str(value) partition
dir -> fall back.
+ def test_partitioned_table_matches_normal_plan(self):
+ # Native decoding restores PyPaimon's legacy unescaped partition path.
schema = pa.schema([('k', pa.int64()), ('p', pa.string())])
self.cat.create_table('default.pt_t', Schema.from_pyarrow_schema(
schema, partition_keys=['p']), False)
@@ -191,14 +323,13 @@ class NativePlanIntegrationTest(unittest.TestCase):
wb = t.new_batch_write_builder()
w, c = wb.new_write(), wb.new_commit()
w.write_arrow(pa.Table.from_pylist(
- [{'k': 1, 'p': 'a'}, {'k': 2, 'p': 'a'}, {'k': 3, 'p': 'b'}],
schema=schema))
+ [{'k': 1, 'p': 'a/b'}, {'k': 2, 'p': 'a/b'}, {'k': 3, 'p': 'c'}],
+ schema=schema))
c.commit(w.prepare_commit())
w.close()
c.close()
- native_table = self.cat.get_table('default.pt_t').copy(
- {'scan.native-plan.enabled': 'true'})
-
self.assertFalse(native_table.new_read_builder().explain().native_planned)
+ self._assert_matches('pt_t')
def test_explain_reflects_native_plan(self):
self.cat.create_table(
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py
b/paimon-python/pypaimon/tests/native_plan_test.py
index b21ac808a6..b590bb4f90 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -26,7 +26,15 @@ 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.read.native_plan import _catalog_options, native_plan
+from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_builder import PredicateBuilder
+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
@@ -60,6 +68,8 @@ def _scan(native_enabled, file_scanner):
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
@@ -109,7 +119,8 @@ class NativePlanTest(unittest.TestCase):
with patch('pypaimon.read.native_plan.native_plan',
return_value=[keep, drop]) as np:
plan = scan.plan()
- np.assert_called_once_with(scan.table)
+ np.assert_called_once_with(
+ scan.table, predicate=None, limit=None, projection=None)
fs.scan.assert_not_called()
self.assertEqual(plan.splits(), [keep])
@@ -132,9 +143,39 @@ class NativePlanTest(unittest.TestCase):
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'],
+ )
+
def test_plan_falls_back_when_scan_is_not_plain(self):
# Native planning does not carry shard/slice, global-index, or
- # time-travel/incremental scans -> must fall back to the file scanner.
+ # incremental scans -> must fall back to the file scanner.
def check(setup):
fs = Mock(partition_key_predicate=None)
sentinel = object()
@@ -151,7 +192,6 @@ class NativePlanTest(unittest.TestCase):
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, 'deletion_vectors_enabled', True))
- check(lambda s, fs: setattr(fs, 'data_evolution', 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', [])))
@@ -161,11 +201,9 @@ class NativePlanTest(unittest.TestCase):
'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, 'partition_keys', ['dt']))
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: setattr(s, 'limit', 5))
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__(
@@ -181,8 +219,6 @@ class NativePlanTest(unittest.TestCase):
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_key.__setattr__(
- 'return_value', True)) # time-travel
check(lambda s, fs: s.table.options.options.contains.__setattr__(
'return_value', True)) # incremental
@@ -271,7 +307,8 @@ class NativePlanTest(unittest.TestCase):
self.assertIs(plan, fallback_plan)
self.assertIs(stats, fallback_stats)
- np.assert_called_once_with(scan.table)
+ np.assert_called_once_with(
+ scan.table, predicate=None, limit=None, projection=None)
fs.scan_with_stats.assert_called_once_with()
fs.scan.assert_not_called()
@@ -316,14 +353,120 @@ class NativePlanTest(unittest.TestCase):
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()