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 0bc71fb402 [python] Use Native scan statistics in explain (#10609)
0bc71fb402 is described below
commit 0bc71fb402eb128e4b73c24ba5809177613859ed
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 08:54:49 2026 +0800
[python] Use Native scan statistics in explain (#10609)
---
paimon-python/README.md | 18 +-
paimon-python/conftest.py | 4 +-
paimon-python/pypaimon/catalog/table_query_auth.py | 4 +-
paimon-python/pypaimon/read/explain.py | 21 +-
paimon-python/pypaimon/read/native_plan.py | 5 +-
paimon-python/pypaimon/read/plan.py | 32 +-
paimon-python/pypaimon/read/read_builder.py | 10 +-
paimon-python/pypaimon/read/scan_stats.py | 77 ++++-
.../read/scanner/bucket_select_converter.py | 7 +-
.../pypaimon/read/scanner/file_scanner.py | 1 +
paimon-python/pypaimon/read/table_scan.py | 13 +-
.../pypaimon/tests/native_bucket_selector_test.py | 235 +++++++++++++
.../pypaimon/tests/native_plan_integration_test.py | 11 +-
paimon-python/pypaimon/tests/native_plan_test.py | 39 ++-
.../pypaimon/tests/native_scan_stats_test.py | 375 +++++++++++++++++++++
.../pypaimon/tests/pushdown_bucket_test.py | 19 +-
.../pypaimon/tests/table_query_auth_test.py | 52 +--
17 files changed, 856 insertions(+), 67 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index b0f5a2eb3e..ccb3cb5c18 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -362,7 +362,23 @@ print(explanation.native_planned)
The adapter checks the installed binding's capabilities and falls back to the
Python planner for unsupported scans. New bindings preserve `plan.snapshot_id`
even when pruning removes every split. Native explain output includes snapshot
-and split metadata; native pruning counters are not exposed.
+and split metadata plus counters for actual Native planning work, available in
+`explanation.planning_counters`. Statistics collection retains early manifest,
+sidecar and LIMIT optimizations. The Python scanner's before/after pruning
funnel
+is unavailable for Native plans because skipped entries are not decoded.
+Final split, file and byte totals describe the returned Plan after Python's
+post-processing and deferred LIMIT; decoding counters retain the Native work.
+Every Native plan includes lightweight counters. Use
+`plan = builder.new_scan().plan()` followed by `plan.stats()`; conversion to
+Python counters is deferred until this getter is called. The existing
+`plan, stats = builder.new_scan().scan_with_stats()` API remains available
+for both planners. Snapshot metadata is available separately as
`plan.snapshot_id`.
+
+Fixed-bucket Native planning also handles ORs of equality/IN predicates,
+intersects repeated key constraints, and specializes predicates per partition.
+It follows Java's 1000-candidate combination limit and keeps scans conservative
+when a predicate cannot determine buckets. Masked fields do not drive raw-value
+bucket pruning.
To run both split planning and data-file reading in Rust, enable the
independent
native-read option:
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index ef969ffb34..ee07dc8313 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -104,9 +104,9 @@ def pytest_configure(config):
original_plan = TableScan._try_native_plan
- def tracked_plan(self):
+ def tracked_plan(self, *args, **kwargs):
global _native_plan_count
- plan = original_plan(self)
+ plan = original_plan(self, *args, **kwargs)
if plan is not None and _force_native_for_test:
_native_plan_count += 1
return plan
diff --git a/paimon-python/pypaimon/catalog/table_query_auth.py
b/paimon-python/pypaimon/catalog/table_query_auth.py
index 04983ee97e..b4fc309dd0 100644
--- a/paimon-python/pypaimon/catalog/table_query_auth.py
+++ b/paimon-python/pypaimon/catalog/table_query_auth.py
@@ -17,6 +17,7 @@
################################################################################
import json
+from dataclasses import replace
from typing import Callable, Dict, List, Optional
import pyarrow as pa
@@ -26,7 +27,6 @@ from pypaimon.common.predicate_json_parser import (
extract_referenced_fields,
parse_predicate_to_batch_filter,
)
-from pypaimon.read.plan import Plan
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.schema.data_types import DataField
from pypaimon.table.special_fields import SpecialFields
@@ -132,7 +132,7 @@ class TableQueryAuthResult:
if not self.has_restrictions:
return plan
auth_splits = [QueryAuthSplit(split, self) for split in plan.splits()]
- return Plan(auth_splits, snapshot_id=plan.snapshot_id)
+ return replace(plan, _splits=auth_splits)
def extract_row_filter(self) -> Optional[Callable[[pa.RecordBatch],
pa.Array]]:
if not self.filter:
diff --git a/paimon-python/pypaimon/read/explain.py
b/paimon-python/pypaimon/read/explain.py
index 5cc1cb1bd8..cc1b0516b2 100644
--- a/paimon-python/pypaimon/read/explain.py
+++ b/paimon-python/pypaimon/read/explain.py
@@ -130,9 +130,10 @@ class ExplainResult:
# Verbose-only
splits: Optional[List[ExplainSplitInfo]] = None
- # Native (pypaimon_rust) plan; pruning funnel not tracked. Kept last to
- # preserve positional-arg compatibility.
+ # Whether the same core Native pass produced the plan and pruning counters.
native_planned: bool = False
+ # Actual work performed by Native planning, retaining early metadata
pruning.
+ planning_counters: Optional[Dict[str, Any]] = None
def __str__(self) -> str:
return render_explain(self)
@@ -168,7 +169,7 @@ def render_explain(result: ExplainResult) -> str:
out.write("\n")
if result.native_planned:
- _line(out, "Planner", "native (pypaimon_rust); pruning not tracked")
+ _line(out, "Planner", "native (pypaimon_rust)")
_line(out, "Partition pruning",
result.partition_pruning.format() if result.partition_pruning else
"n/a")
_line(out, "Bucket pruning",
@@ -176,6 +177,20 @@ def render_explain(result: ExplainResult) -> str:
_line(out, "File skipping",
result.file_skipping.format() if result.file_skipping else "n/a")
+ if result.planning_counters is not None:
+ out.write("\nNative planning counters\n")
+ for label, name in (
+ ('Manifest entries decoded', 'manifest_entries_read'),
+ ('Bucket rejects', 'manifest_entries_pruned_by_bucket'),
+ ('Partition rejects', 'manifest_entries_pruned_by_partition'),
+ ('File statistics rejects',
'manifest_entries_pruned_by_data_stats'),
+ ('Cross-schema rejects',
'manifest_entries_pruned_by_cross_schema_stats'),
+ ('Manifest row-range skips',
'manifest_files_pruned_by_row_ranges'),
+ ('Split candidates built', 'split_candidates_built'),
+ ('LIMIT stopped planning', 'limit_early_stopped'),
+ ):
+ _line(out, label, str(result.planning_counters[name]))
+
out.write("\n")
_line(out, "Splits", str(result.split_count))
if result.split_count > 0:
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index b3c8267598..f968f85154 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -514,7 +514,8 @@ def native_plan(
scan = scan.with_chunk_shuffle(str(seed), chunk_size)
if shard is not None:
scan = scan.with_shard(*shard)
- return _from_native_plan(table, scan.plan())
+ rust_plan = scan.plan()
+ return _from_native_plan(table, rust_plan)
def _native_stream_auth_supported(table):
@@ -576,4 +577,4 @@ def _from_native_plan(table, rust_plan) -> Plan:
# Older bindings cannot distinguish an empty committed snapshot from a
# table without snapshots. Let the Python scanner recover the metadata.
raise RuntimeError("Native runtime cannot report an empty plan's
snapshot")
- return Plan(splits, snapshot_id=snapshot_id)
+ return Plan(splits, snapshot_id=snapshot_id,
_native_stats=rust_plan.stats())
diff --git a/paimon-python/pypaimon/read/plan.py
b/paimon-python/pypaimon/read/plan.py
index dc7081bea7..88746311dc 100644
--- a/paimon-python/pypaimon/read/plan.py
+++ b/paimon-python/pypaimon/read/plan.py
@@ -16,10 +16,11 @@
# under the License.
from dataclasses import dataclass, field
-from typing import List, Optional
+from typing import Any, List, Optional
from pypaimon.read.split import Split
+from pypaimon.read.scan_stats import ScanStats
@dataclass
@@ -27,6 +28,35 @@ class Plan:
"""Implementation of Plan for native Python reading."""
_splits: List[Split]
snapshot_id: Optional[int] = field(default=None)
+ _scan_stats: Optional[ScanStats] = field(default=None, repr=False,
compare=False)
+ _native_stats: Any = field(default=None, repr=False, compare=False)
+ # A LIMIT hint deferred until after Native row-position selection.
+ _stats_limit: Optional[int] = field(default=None, repr=False,
compare=False)
def splits(self) -> List[Split]:
return self._splits
+
+ def stats(self) -> Optional[ScanStats]:
+ """Planning counters, converted from Native only when requested."""
+ native_stats = self._native_stats
+ if native_stats is not None:
+ self._scan_stats = ScanStats.from_native(native_stats)
+ # Python can filter or limit splits after Native planning. Keep the
+ # actual planning work, but summarize the final returned selection.
+ files, data_bytes = 0, 0
+ for split in self._splits:
+ for file in split.files:
+ files += 1
+ data_bytes += max(0, file.file_size)
+ counters = self._scan_stats.planning_counters
+ counters.update(final_splits=len(self._splits),
splits_after_limit=len(self._splits),
+ final_files=files,
planned_data_file_bytes=min(data_bytes, 2 ** 64 - 1))
+ if self._stats_limit is not None:
+ counters['limit'] = self._stats_limit
+ self._native_stats = None
+ return self._scan_stats
+
+ def __getstate__(self):
+ # Native handles are process-local. Serialize counters as Python
scalars.
+ self.stats()
+ return self.__dict__.copy()
diff --git a/paimon-python/pypaimon/read/read_builder.py
b/paimon-python/pypaimon/read/read_builder.py
index f990d7ded6..cb5373cdc8 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -226,8 +226,8 @@ class ReadBuilder:
Cost: ``explain()`` reads manifest list + manifests but never
opens data files. To produce accurate before/after counters it
- suppresses the manifest-reader's early bucket filter and forces
- single-threaded manifest decoding, so it can be measurably
+ suppresses early bucket and sidecar pruning and counts candidates
+ before applying LIMIT, so it can be measurably
heavier than a regular ``new_scan().plan()`` on tables where the
early filter usually prunes aggressively (e.g. very wide
HASH_FIXED tables with a tight predicate).
@@ -482,9 +482,8 @@ def _build_explain_result(table, scan: TableScan, plan,
stats: Optional[ScanStat
table_schema = table.table_schema
bucket_mode_str = _safe_bucket_mode(table)
- # Native plans expose split metadata without Python pruning counters.
- native_planned = stats is None
- if native_planned:
+ native_planned = stats is None or stats.native_planned
+ if stats is None or native_planned:
partition_pruning = bucket_pruning = file_skipping = None
else:
partition_pruning = _partition_pruning(stats, scan)
@@ -586,6 +585,7 @@ def _build_explain_result(table, scan: TableScan, plan,
stats: Optional[ScanStat
split_size_p95=sz_p95,
has_auth=plan_has_auth,
native_planned=native_planned,
+ planning_counters=stats.planning_counters if stats is not None else
None,
splits=split_infos if verbose else None,
)
diff --git a/paimon-python/pypaimon/read/scan_stats.py
b/paimon-python/pypaimon/read/scan_stats.py
index 6626b0c240..2e7de036c3 100644
--- a/paimon-python/pypaimon/read/scan_stats.py
+++ b/paimon-python/pypaimon/read/scan_stats.py
@@ -16,7 +16,38 @@
# under the License.
from dataclasses import dataclass, field
-from typing import Set, Tuple
+from typing import Any, Dict, Optional, Set, Tuple
+
+
+_NATIVE_COUNTER_FIELDS = (
+ 'base_manifest_files',
+ 'delta_manifest_files',
+ 'manifest_files_before_partition_pruning',
+ 'manifest_files_after_partition_pruning',
+ 'manifest_files_pruned_by_row_ranges',
+ 'manifest_entries_read',
+ 'manifest_entries_pruned_by_bucket',
+ 'manifest_entries_pruned_by_partition',
+ 'manifest_entries_after_entry_pruning',
+ 'manifest_entries_pruned_by_level',
+ 'manifest_entries_pruned_by_row_ranges',
+ 'manifest_entries_pruned_by_data_stats',
+ 'manifest_entries_after_manifest_filters',
+ 'manifest_entries_after_merge',
+ 'manifest_entries_pruned_by_cross_schema_stats',
+ 'manifest_entries_after_cross_schema_stats',
+ 'data_evolution_groups_before_stats',
+ 'data_evolution_groups_pruned_by_stats',
+ 'data_evolution_groups_pruned_by_row_ranges',
+ 'split_candidates_built',
+ 'limit_early_stopped',
+ 'splits_before_limit',
+ 'splits_after_limit',
+ 'final_splits',
+ 'final_files',
+ 'planned_data_file_bytes',
+ 'limit',
+)
@dataclass
@@ -38,14 +69,42 @@ class ScanStats:
# manifest-level partition skip. ``entries_total`` is what actually reached
# ``_filter_manifest_entry``; the gap between the two captures
# manifest-level pruning.
- entries_potential_total: int = 0
+ entries_potential_total: Optional[int] = 0
entries_total: int = 0
- entries_after_partition: int = 0
- entries_after_bucket: int = 0
- entries_after_stats: int = 0
+ entries_after_partition: Optional[int] = 0
+ entries_after_bucket: Optional[int] = 0
+ entries_after_stats: Optional[int] = 0
+
+ # Identity sets are Python-scanner bookkeeping. Native returns counters
+ # without transporting partition values back across the language boundary.
+ partition_keys_before: Optional[Set[Tuple]] = field(default_factory=set)
+ partition_keys_after: Optional[Set[Tuple]] = field(default_factory=set)
+
+ buckets_seen: Optional[Set[Tuple[Tuple, int]]] = field(default_factory=set)
+ buckets_after_pruning: Optional[Set[Tuple[Tuple, int]]] =
field(default_factory=set)
+ native_planned: bool = False
+ # Native counters describe actual planning work; skipped metadata is not
decoded
+ # to reconstruct the Python scanner's before/after funnel.
+ planning_counters: Optional[Dict[str, Any]] = None
+
+ @classmethod
+ def from_native(cls, stats):
+ return cls._from_native_counters({name: getattr(stats, name) for name
in _NATIVE_COUNTER_FIELDS})
- partition_keys_before: Set[Tuple] = field(default_factory=set)
- partition_keys_after: Set[Tuple] = field(default_factory=set)
+ @classmethod
+ def empty_native(cls, limit=None):
+ counters = dict.fromkeys(_NATIVE_COUNTER_FIELDS, 0)
+ counters.update(limit=limit, limit_early_stopped=False)
+ return cls._from_native_counters(counters)
- buckets_seen: Set[Tuple[Tuple, int]] = field(default_factory=set)
- buckets_after_pruning: Set[Tuple[Tuple, int]] = field(default_factory=set)
+ @classmethod
+ def _from_native_counters(cls, counters):
+ return cls(
+
manifest_files_total=counters['manifest_files_before_partition_pruning'],
+
manifest_files_after_partition=counters['manifest_files_after_partition_pruning'],
+ entries_total=counters['manifest_entries_read'],
+ entries_potential_total=None, entries_after_partition=None,
+ entries_after_bucket=None, entries_after_stats=None,
+ planning_counters=counters,
+ partition_keys_before=None, partition_keys_after=None,
+ buckets_seen=None, buckets_after_pruning=None, native_planned=True)
diff --git a/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
b/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
index c952ff886a..6a9cd060f5 100644
--- a/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
+++ b/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
@@ -392,7 +392,7 @@ class _Selector:
# ``total_buckets <= 0`` shows up for postpone / legacy / special
# entries and must NOT be pruned: returning False here would drop
# rows the writer hashed under a different convention. Fail open.
- if total_buckets <= 0:
+ if bucket < 0 or total_buckets <= 0:
return True
try:
return bucket in self._compute(partition, total_buckets)
@@ -430,8 +430,9 @@ class _Selector:
self._predicate, partition_field_names, partition_values)
if effective_predicate is _AlwaysFalse:
- # No row in this partition can match — empty bucket set.
- frozen: FrozenSet[int] = frozenset()
+ # Java cannot extract Equal/In values from AlwaysFalse. Leave
+ # rejection to the partition predicate rather than the key hash.
+ frozen: FrozenSet[int] = frozenset(range(total_buckets))
self._cache[cache_key] = frozen
return frozen
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index 22779bdfc5..bcbf73e033 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -699,6 +699,7 @@ class FileScanner:
"""
self.scan_stats = ScanStats()
plan = self.scan()
+ plan._scan_stats = self.scan_stats
return plan, self.scan_stats
def with_chunk_shuffle(self, seed: int, chunk_size: int) -> 'FileScanner':
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index c3d4edc504..ed3bfd9aab 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -18,6 +18,7 @@
import json as _json
import logging
import re
+from dataclasses import replace
from typing import List, Optional, Tuple
from pypaimon.catalog.catalog_exception import TableNoPermissionException
@@ -200,7 +201,7 @@ class TableScan:
extra_options = {}
if
self.table.options.options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP):
if self._incremental_snapshot_range is None:
- return Plan([])
+ return Plan([],
_scan_stats=ScanStats.empty_native(self.limit))
extra_options['incremental_range'] =
self._incremental_snapshot_range
if self.table.options.changelog_producer() !=
ChangelogProducer.NONE:
extra_options['incremental_mode'] = 'changelog'
@@ -267,7 +268,7 @@ class TableScan:
if not (getattr(fs, 'deletion_vectors_enabled', False)
and not self.table.is_primary_key_table):
splits = fs._apply_push_down_limit(splits)
- return Plan(splits, snapshot_id=plan.snapshot_id)
+ return replace(plan, _splits=splits, _stats_limit=self.limit if
defer_limit else None)
except Exception as e:
_raise_if_native_fork_safety_error(e)
# Any native construction/planning/pruning failure -> fall back.
@@ -296,15 +297,15 @@ class TableScan:
"""Run :meth:`plan` while recording manifest / pruning counters.
Only used by :meth:`ReadBuilder.explain`; the regular read path
- keeps going through :meth:`plan`. Native plans return stats=None;
- explain reports their split metadata without pruning counters.
+ keeps going through :meth:`plan`. Native scans always collect
lightweight
+ counters in the same core planning pass, without opening data files.
"""
native_auth = (self.table.options.query_auth_enabled
and self._native_requested() and
self._native_plan_supported())
if native_auth:
native = self._try_native_plan()
if native is not None:
- return native, None
+ return native, native.stats()
auth_result = self.__auth_query()
if self._query_auth_fn is not None:
prune_scanner_by_auth(self.table, self.file_scanner, auth_result)
@@ -312,7 +313,7 @@ class TableScan:
and self._native_plan_supported()):
native = self._try_native_plan()
if native is not None:
- return native, None
+ return native, native.stats()
plan, stats = self.file_scanner.scan_with_stats()
return wrap_plan_with_auth(auth_result, plan), stats
diff --git a/paimon-python/pypaimon/tests/native_bucket_selector_test.py
b/paimon-python/pypaimon/tests/native_bucket_selector_test.py
new file mode 100644
index 0000000000..eae06eaf8c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_bucket_selector_test.py
@@ -0,0 +1,235 @@
+# 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.read.native_plan 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.object(scan.file_scanner, 'scan',
+
side_effect=AssertionError('Native scan fell back')))
+ stack.enter_context(patch.object(scan.file_scanner,
'scan_with_stats',
+
side_effect=AssertionError('Native statistics fell back')))
+
stack.enter_context(patch('pypaimon.read.table_read.TableRead._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)
+
+
[email protected]('primary_key', [False, True], ids=['append', 'pk'])
[email protected]('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
+
+
[email protected]('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
+
+
[email protected]('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)
+
+
[email protected]('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)
+
+
[email protected]('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)
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index 7f0647b492..3ea5117708 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -1548,7 +1548,7 @@ class NativePlanIntegrationTest(unittest.TestCase):
@unittest.skipUnless(native_method_available('Plan', 'snapshot_id'),
"pypaimon_rust snapshot metadata API not installed")
- def
test_native_explain_reports_split_metadata_without_pruning_counters(self):
+ def test_native_explain_reports_split_metadata_and_pruning_counters(self):
self.cat.create_table(
'default.explain_metadata_t', Schema.from_pyarrow_schema(
self.schema, options={'metadata.stats-mode': 'full'}), False)
@@ -1563,13 +1563,20 @@ class NativePlanIntegrationTest(unittest.TestCase):
self.assertEqual(result.snapshot_id, 2)
self.assertEqual(result.split_count, 1)
self.assertEqual(result.file_count, 1)
- self.assertIn('pruning not tracked', str(result))
+ self.assertNotIn('pruning not tracked', str(result))
self.assertIsNone(result.file_skipping)
+ self.assertEqual(result.planning_counters['manifest_entries_read'], 2)
+
self.assertEqual(result.planning_counters['manifest_entries_pruned_by_data_stats'],
1)
+ self.assertEqual(result.planning_counters['final_files'], 1)
builder.with_filter(builder.new_predicate_builder().equal('k', 99))
empty = builder.explain()
self.assertTrue(empty.native_planned)
self.assertEqual(empty.snapshot_id, 2)
self.assertEqual(empty.split_count, 0)
+ self.assertIsNone(empty.file_skipping)
+ self.assertEqual(empty.planning_counters['manifest_entries_read'], 2)
+
self.assertEqual(empty.planning_counters['manifest_entries_pruned_by_data_stats'],
2)
+ self.assertEqual(empty.planning_counters['final_files'], 0)
def test_empty_table_explain_preserves_native_metadata(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 f9b629d4e4..5e1adde4ad 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -99,6 +99,32 @@ def _scan(native_enabled, file_scanner):
class NativePlanTest(unittest.TestCase):
+ def
test_native_final_statistics_preserve_work_and_saturate_unknown_sizes(self):
+ from pypaimon.read.scan_stats import ScanStats
+ counters = ScanStats.empty_native()
+ counters.planning_counters.update(
+ manifest_entries_read=10, split_candidates_built=5,
splits_before_limit=5)
+ maximum = 2 ** 63 - 1
+ splits = [SimpleNamespace(files=[SimpleNamespace(file_size=size) for
size in sizes])
+ for sizes in [[maximum, -1], [maximum, 2]]]
+ plan = Plan(splits, 7, _native_stats=object(), _stats_limit=1)
+ with patch.object(ScanStats, 'from_native', return_value=counters) as
convert:
+ stats = plan.stats()
+ self.assertIs(plan.stats(), stats)
+ convert.assert_called_once()
+ actual = stats.planning_counters
+ self.assertEqual(actual['manifest_entries_read'], 10)
+ self.assertEqual(actual['split_candidates_built'], 5)
+ self.assertEqual(actual['splits_before_limit'], 5)
+ self.assertEqual(actual['final_splits'], 2)
+ self.assertEqual(actual['splits_after_limit'], 2)
+ self.assertEqual(actual['final_files'], 4)
+ self.assertEqual(actual['planned_data_file_bytes'], 2 ** 64 - 1)
+ self.assertEqual(actual['limit'], 1)
+ zero = Plan([], 7, _native_stats=object(), _stats_limit=0)
+ with patch.object(ScanStats, 'from_native',
return_value=ScanStats.empty_native()):
+ self.assertEqual(zero.stats().planning_counters['limit'], 0)
+
def test_variant_read_type_matches_java_interop_fixture(self):
actual = _variant_read_type_json(
[DataField(7, 'payload', AtomicType('VARIANT'))],
@@ -643,13 +669,16 @@ class NativePlanTest(unittest.TestCase):
fs.scan.assert_called_once_with()
def test_scan_with_stats_preserves_native_empty_snapshot(self):
+ from pypaimon.read.scan_stats import ScanStats
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
- native = Plan([], 7)
+ counters = ScanStats.empty_native()
+ native = Plan([], 7, _scan_stats=counters)
with patch('pypaimon.read.native_plan.native_plan',
return_value=native) as np:
plan, stats = scan.scan_with_stats()
self.assertEqual(plan.snapshot_id, 7)
- self.assertIsNone(stats)
+ self.assertIs(stats, counters)
+ self.assertIs(plan.stats(), counters)
np.assert_called_once_with(
scan.table, predicate=None, limit=None, projection=None,
row_ranges=None)
@@ -1213,7 +1242,7 @@ class NativePlanTest(unittest.TestCase):
with self.subTest(snapshot_id=snapshot_id, legacy=legacy):
table = _scan(True, Mock()).table
table.table_schema = Mock(fields=[], partition_keys=[])
- rust_plan = SimpleNamespace(splits=lambda: [])
+ rust_plan = SimpleNamespace(splits=lambda: [], stats=lambda:
None)
if not legacy:
rust_plan.snapshot_id = lambda: snapshot_id
scan = SimpleNamespace(plan=lambda: rust_plan)
@@ -1233,7 +1262,7 @@ class NativePlanTest(unittest.TestCase):
table.table_schema = TableSchema(0, [])
table.options = CoreOptions(Options({}))
table.current_branch.return_value = 'b1'
- rust_plan = SimpleNamespace(splits=lambda: [], snapshot_id=lambda: 7)
+ rust_plan = SimpleNamespace(splits=lambda: [], stats=lambda: None,
snapshot_id=lambda: 7)
scan = Mock()
scan.plan.return_value = rust_plan
rt = Mock()
@@ -1357,7 +1386,7 @@ class NativePlanTest(unittest.TestCase):
def test_native_plan_forwards_explicit_incremental_mode(self):
table = _scan(True, Mock()).table
table.table_schema = Mock(fields=[], partition_keys=[])
- rust_plan = SimpleNamespace(splits=lambda: [], snapshot_id=lambda: 4)
+ rust_plan = SimpleNamespace(splits=lambda: [], stats=lambda: None,
snapshot_id=lambda: 4)
rust_scan = Mock()
rust_scan.plan.return_value = rust_plan
builder = Mock()
diff --git a/paimon-python/pypaimon/tests/native_scan_stats_test.py
b/paimon-python/pypaimon/tests/native_scan_stats_test.py
new file mode 100644
index 0000000000..a41280abfb
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_scan_stats_test.py
@@ -0,0 +1,375 @@
+# 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.read.native_plan import _native_table, 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.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
+
+
[email protected]('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
+
+
[email protected]('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()]
+
+
[email protected]('selection', ['whole_shard', 'partial_shard',
'whole_slice', 'partial_slice'])
[email protected]('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
+
+
[email protected]('window', ['future', 'equal'])
[email protected]('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
+
+
[email protected]('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)
+
+
[email protected]('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.read.table_read.TableRead._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'}]
diff --git a/paimon-python/pypaimon/tests/pushdown_bucket_test.py
b/paimon-python/pypaimon/tests/pushdown_bucket_test.py
index 8f3c0814cc..0e2ddd74c2 100644
--- a/paimon-python/pypaimon/tests/pushdown_bucket_test.py
+++ b/paimon-python/pypaimon/tests/pushdown_bucket_test.py
@@ -524,11 +524,10 @@ class
PartitionAwareBucketSelectorUnitTest(unittest.TestCase):
self.assertTrue(sel(None, b, 8))
self.assertTrue(sel.may_contain(b, b, 8))
- def test_selector_partition_not_matching_returns_empty_bucket_set(self):
+ def test_selector_partition_not_matching_keeps_java_bucket_fallback(self):
# ``part = 'a' AND id = 1`` on partition {part: 'c'} simplifies to
- # AlwaysFalse — the selector returns False for every bucket since
- # no row in this partition can possibly match. Sound: dropping a
- # partition that *can't* contain matches doesn't lose data.
+ # AlwaysFalse. Java cannot extract bucket-key values and falls back
+ # to all buckets; the independent partition filter rejects this row.
pred = PredicateBuilder.and_predicates([
self.pb.equal('part', 'a'),
self.pb.equal('id', 1),
@@ -538,9 +537,15 @@ class
PartitionAwareBucketSelectorUnitTest(unittest.TestCase):
self.assertIsNotNone(sel)
part_c = GenericRow(['c'], [self.part_field], RowKind.INSERT)
for b in range(8):
- self.assertFalse(sel(part_c, b, 8),
- "partition c can't satisfy part='a', "
- "drop every bucket (b=%d)" % b)
+ self.assertTrue(sel(part_c, b, 8))
+
+ def test_pending_buckets_are_never_pruned_by_key_hash(self):
+ pred = self.pb.equal('id', 7)
+ sel = create_bucket_selector(pred, [self.id_field],
partition_fields=[self.part_field])
+ part = GenericRow(['a'], [self.part_field], RowKind.INSERT)
+ for bucket in [-2, -1]:
+ self.assertTrue(sel(part, bucket, 8))
+ self.assertTrue(sel(bucket, 8))
def test_selector_partition_only_constraint_drops_partition(self):
# ``part='a' AND id IN (1,2)`` — same partition value 'a'
diff --git a/paimon-python/pypaimon/tests/table_query_auth_test.py
b/paimon-python/pypaimon/tests/table_query_auth_test.py
index 56ec0ddb94..9a5c304e8b 100644
--- a/paimon-python/pypaimon/tests/table_query_auth_test.py
+++ b/paimon-python/pypaimon/tests/table_query_auth_test.py
@@ -18,6 +18,7 @@
import json
import unittest
+from unittest.mock import patch
import pyarrow as pa
@@ -28,7 +29,9 @@ from pypaimon.catalog.table_query_auth import (
from pypaimon.common.identifier import Identifier
from pypaimon.common.options import Options
from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.read.plan import Plan
from pypaimon.read.query_auth_split import QueryAuthSplit
+from pypaimon.read.scan_stats import ScanStats
class _FakeField:
@@ -60,15 +63,6 @@ class _FakeSplit:
return 100
-class _FakePlan:
- def __init__(self, splits, snapshot_id=None):
- self._splits = splits
- self.snapshot_id = snapshot_id
-
- def splits(self):
- return self._splits
-
-
class _FakeTable:
def __init__(self, identifier=None):
self.identifier = identifier or Identifier("db", "table")
@@ -124,7 +118,7 @@ class
TestTableQueryAuthResultConvertPlan(unittest.TestCase):
def test_no_auth_returns_original_plan(self):
result = TableQueryAuthResult(None, None)
- plan = _FakePlan([_FakeSplit()])
+ plan = Plan([_FakeSplit()])
converted = result.convert_plan(plan)
self.assertIs(converted, plan)
@@ -134,7 +128,7 @@ class
TestTableQueryAuthResultConvertPlan(unittest.TestCase):
def test_empty_filter_and_masking_returns_original(self):
result = TableQueryAuthResult([], {})
- plan = _FakePlan([_FakeSplit()])
+ plan = Plan([_FakeSplit()])
converted = result.convert_plan(plan)
self.assertIs(converted, plan)
@@ -177,22 +171,42 @@ class
TestTableQueryAuthResultConvertPlan(unittest.TestCase):
def test_wraps_splits_with_filter(self):
result = TableQueryAuthResult([_simple_filter_json()], None)
- plan = _FakePlan([_FakeSplit("s1"), _FakeSplit("s2")])
+ stats = ScanStats(entries_after_stats=2)
+ plan = Plan([_FakeSplit("s1"), _FakeSplit("s2")], _scan_stats=stats)
converted = result.convert_plan(plan)
+ self.assertIs(converted.stats(), stats)
self.assertEqual(len(converted.splits()), 2)
for qs in converted.splits():
self.assertIsInstance(qs, QueryAuthSplit)
+ def test_auth_wrapping_preserves_lazy_native_statistics(self):
+ result = TableQueryAuthResult([_simple_filter_json()], None)
+ handle = object()
+ plan = Plan([_FakeSplit("s1")], snapshot_id=7, _native_stats=handle)
+ counters = ScanStats.empty_native()
+ counters.entries_total = 10
+ counters.planning_counters['manifest_entries_read'] = 10
+ with patch.object(ScanStats, 'from_native', return_value=counters) as
convert:
+ wrapped = result.convert_plan(plan)
+ convert.assert_not_called()
+ self.assertEqual(wrapped.snapshot_id, 7)
+ self.assertIsInstance(wrapped.splits()[0], QueryAuthSplit)
+ self.assertIs(wrapped.stats(), counters)
+ self.assertIs(wrapped.stats(), counters)
+
self.assertEqual(counters.planning_counters['manifest_entries_read'], 10)
+ self.assertEqual(counters.planning_counters['final_splits'], 1)
+ convert.assert_called_once_with(handle)
+
def test_wraps_splits_with_masking(self):
result = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
- plan = _FakePlan([_FakeSplit()])
+ plan = Plan([_FakeSplit()])
converted = result.convert_plan(plan)
self.assertEqual(len(converted.splits()), 1)
self.assertIsInstance(converted.splits()[0], QueryAuthSplit)
def test_inner_split_preserved(self):
result = TableQueryAuthResult([_simple_filter_json()], None)
- plan = _FakePlan([_FakeSplit("original")])
+ plan = Plan([_FakeSplit("original")])
converted = result.convert_plan(plan)
self.assertEqual(converted.splits()[0].split.name, "original")
@@ -370,7 +384,7 @@ class TestPlanForWrite(unittest.TestCase):
def test_no_restriction_returns_plan_unchanged(self):
from pypaimon.read.table_scan import TableScan
- plain_plan = _FakePlan([_FakeSplit()], snapshot_id=5)
+ plain_plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plain_plan, auth_result=None)
result = TableScan.plan_for_write(scan)
self.assertIs(result, plain_plan)
@@ -379,7 +393,7 @@ class TestPlanForWrite(unittest.TestCase):
from pypaimon.read.table_scan import TableScan
auth = TableQueryAuthResult([_simple_filter_json()], None)
- plan = _FakePlan([_FakeSplit()], snapshot_id=5)
+ plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plan, auth_result=auth)
with self.assertRaises(TableNoPermissionException):
TableScan.plan_for_write(scan)
@@ -388,7 +402,7 @@ class TestPlanForWrite(unittest.TestCase):
from pypaimon.read.table_scan import TableScan
auth = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
- plan = _FakePlan([_FakeSplit()], snapshot_id=5)
+ plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plan, auth_result=auth)
with self.assertRaises(TableNoPermissionException):
TableScan.plan_for_write(scan)
@@ -397,7 +411,7 @@ class TestPlanForWrite(unittest.TestCase):
from pypaimon.read.table_scan import TableScan
auth = TableQueryAuthResult([_simple_filter_json()], None)
- empty_plan = _FakePlan([], snapshot_id=5)
+ empty_plan = Plan([], snapshot_id=5)
scan = _FakeScan(empty_plan, auth_result=auth)
with self.assertRaises(TableNoPermissionException):
TableScan.plan_for_write(scan)
@@ -405,7 +419,7 @@ class TestPlanForWrite(unittest.TestCase):
def test_no_restrictions_allows_write(self):
from pypaimon.read.table_scan import TableScan
- plan = _FakePlan([_FakeSplit()], snapshot_id=5)
+ plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plan, auth_result=None)
result = TableScan.plan_for_write(scan)
self.assertIs(result, plan)