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 6944e9ef9b [python][ray] Flatten large self-merge predicates (#9361)
6944e9ef9b is described below
commit 6944e9ef9b9100069f7319efd199e6c981348e17
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Aug 23 16:04:04 2026 +0800
[python][ray] Flatten large self-merge predicates (#9361)
---
paimon-python/pypaimon/common/predicate.py | 27 +++++++++---
paimon-python/pypaimon/ray/merge_condition.py | 25 +++++++----
paimon-python/pypaimon/read/push_down_utils.py | 24 ++++++++---
paimon-python/pypaimon/read/split_read.py | 13 +++++-
paimon-python/pypaimon/tests/predicates_test.py | 20 +++++++++
.../tests/projection_predicate_index_test.py | 12 ++++++
.../tests/ray_data_evolution_merge_into_test.py | 49 ++++++++++++++++++++++
.../pypaimon/tests/reader_append_only_test.py | 20 +++++++++
8 files changed, 168 insertions(+), 22 deletions(-)
diff --git a/paimon-python/pypaimon/common/predicate.py
b/paimon-python/pypaimon/common/predicate.py
index 0bd1edd774..d93bbedfea 100644
--- a/paimon-python/pypaimon/common/predicate.py
+++ b/paimon-python/pypaimon/common/predicate.py
@@ -18,7 +18,6 @@
import re
from abc import ABC, ABCMeta, abstractmethod
from dataclasses import dataclass
-from functools import reduce
from typing import Any, Dict, List, Optional
from typing import ClassVar
@@ -30,6 +29,20 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.table.row.internal_row import InternalRow
+def _combine_arrow_expressions(expressions, combine):
+ while len(expressions) > 1:
+ next_level = []
+ for index in range(0, len(expressions), 2):
+ if index + 1 == len(expressions):
+ next_level.append(expressions[index])
+ else:
+ next_level.append(combine(
+ expressions[index], expressions[index + 1],
+ ))
+ expressions = next_level
+ return expressions[0]
+
+
@dataclass
class Predicate:
method: str
@@ -113,11 +126,15 @@ class Predicate:
def to_arrow(self) -> Any:
if self.method == 'and':
- return reduce(lambda x, y: x & y,
- [p.to_arrow() for p in self.literals])
+ return _combine_arrow_expressions(
+ [p.to_arrow() for p in self.literals],
+ lambda left, right: left & right,
+ )
if self.method == 'or':
- return reduce(lambda x, y: x | y,
- [p.to_arrow() for p in self.literals])
+ return _combine_arrow_expressions(
+ [p.to_arrow() for p in self.literals],
+ lambda left, right: left | right,
+ )
if self.method == 'startsWith':
pattern = self.literals[0]
diff --git a/paimon-python/pypaimon/ray/merge_condition.py
b/paimon-python/pypaimon/ray/merge_condition.py
index 291c9f20d5..cfbb2ada80 100644
--- a/paimon-python/pypaimon/ray/merge_condition.py
+++ b/paimon-python/pypaimon/ray/merge_condition.py
@@ -162,15 +162,22 @@ def _to_paimon_predicate(expression, builder,
fields_by_name):
if kind == 'BinaryExpr':
op = node.op().upper()
if op in ('AND', 'OR'):
- left = _to_paimon_predicate(
- node.left(), builder, fields_by_name,
- )
- right = _to_paimon_predicate(
- node.right(), builder, fields_by_name,
- )
- if left is None or right is None:
- return None
- predicates = [left, right]
+ predicates = []
+ pending = [expression]
+ while pending:
+ current = pending.pop()
+ if current.variant_name() == 'BinaryExpr':
+ current_node = current.to_variant()
+ if current_node.op().upper() == op:
+ pending.append(current_node.right())
+ pending.append(current_node.left())
+ continue
+ predicate = _to_paimon_predicate(
+ current, builder, fields_by_name,
+ )
+ if predicate is None:
+ return None
+ predicates.append(predicate)
if op == 'AND':
return PredicateBuilder.and_predicates(predicates)
return PredicateBuilder.or_predicates(predicates)
diff --git a/paimon-python/pypaimon/read/push_down_utils.py
b/paimon-python/pypaimon/read/push_down_utils.py
index ebf4da3be5..1b096053e1 100644
--- a/paimon-python/pypaimon/read/push_down_utils.py
+++ b/paimon-python/pypaimon/read/push_down_utils.py
@@ -28,6 +28,10 @@ _UNSAFE_ARROW_FILTER_METHODS = frozenset([
'like',
])
+# Large boolean trees can overflow or crash native Dataset scanners even when
+# balanced. Keep complex predicates on Paimon's exact row-filter path instead.
+_MAX_ARROW_FILTER_LEAVES = 256
+
def extract_partition_spec_from_predicate(
predicate: Predicate, partition_keys: List[str]
@@ -149,12 +153,20 @@ def predicate_supports_arrow_filter(predicate:
Optional[Predicate]) -> bool:
"""
if predicate is None:
return True
- if predicate.method == 'and' or predicate.method == 'or':
- return all(
- predicate_supports_arrow_filter(p)
- for p in (predicate.literals or [])
- )
- return predicate.method not in _UNSAFE_ARROW_FILTER_METHODS
+
+ leaves = 0
+ pending = [predicate]
+ while pending:
+ current = pending.pop()
+ if current.method == 'and' or current.method == 'or':
+ pending.extend(current.literals or [])
+ continue
+ if current.method in _UNSAFE_ARROW_FILTER_METHODS:
+ return False
+ leaves += 1
+ if leaves > _MAX_ARROW_FILTER_LEAVES:
+ return False
+ return True
def remove_row_id_filter(predicate: Predicate) -> Optional[Predicate]:
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index 1085802dd5..b647e713c4 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -34,6 +34,7 @@ from pypaimon.read.interval_partition import
IntervalPartition, SortedRun
from pypaimon.read.partition_info import PartitionInfo
from pypaimon.read.push_down_utils import (
predicate_field_names,
+ predicate_supports_arrow_filter,
rewrite_predicate_indices,
trim_predicate_by_fields,
)
@@ -143,6 +144,8 @@ class SplitRead(ABC):
self.table: FileStoreTable = table
self.predicate = predicate
self.push_down_predicate = self._push_down_predicate()
+ self._arrow_filter_pushdown_enabled = predicate_supports_arrow_filter(
+ self.push_down_predicate)
self.split = split
self.row_tracking_enabled = row_tracking_enabled
self.value_arity = len(read_type)
@@ -558,7 +561,11 @@ class SplitRead(ABC):
if _is_reachable(read_field)
]
read_predicate =
trim_predicate_by_fields(self.push_down_predicate, read_file_fields)
- read_arrow_predicate = read_predicate.to_arrow() if read_predicate
else None
+ read_arrow_predicate = (
+ read_predicate.to_arrow()
+ if read_predicate and self._arrow_filter_pushdown_enabled
+ else None
+ )
self.schema_id_2_fields[key] = (
read_file_fields,
read_arrow_predicate,
@@ -855,7 +862,9 @@ class RawFileSplitRead(SplitRead):
blob_field_indices=blob_field_indices(self.read_fields),
vector_field_indices=vector_field_indices(self.read_fields))
reader = concat_reader
- if self.table.is_primary_key_table and self.predicate_for_reader:
+ if (self.predicate_for_reader
+ and (self.table.is_primary_key_table
+ or not self._arrow_filter_pushdown_enabled)):
reader = FilterRecordBatchReader(
reader,
self.predicate_for_reader,
diff --git a/paimon-python/pypaimon/tests/predicates_test.py
b/paimon-python/pypaimon/tests/predicates_test.py
index ff98be7937..918c95c578 100644
--- a/paimon-python/pypaimon/tests/predicates_test.py
+++ b/paimon-python/pypaimon/tests/predicates_test.py
@@ -49,6 +49,26 @@ def _random_format():
class PredicateTest(unittest.TestCase):
+ def test_large_boolean_arrow_expression_is_balanced(self):
+ class Expression:
+ def __init__(self, depth=1):
+ self.depth = depth
+
+ def __or__(self, other):
+ return Expression(max(self.depth, other.depth) + 1)
+
+ predicates = []
+ for _ in range(2000):
+ predicate = Predicate('equal', 0, 'id', [1])
+ predicate.to_arrow = lambda: Expression()
+ predicates.append(predicate)
+
+ expression = Predicate(
+ 'or', None, None, predicates,
+ ).to_arrow()
+
+ self.assertLessEqual(expression.depth, 12)
+
@classmethod
def setUpClass(cls):
cls.tempdir = tempfile.mkdtemp()
diff --git a/paimon-python/pypaimon/tests/projection_predicate_index_test.py
b/paimon-python/pypaimon/tests/projection_predicate_index_test.py
index 2953456223..8016a1b6da 100644
--- a/paimon-python/pypaimon/tests/projection_predicate_index_test.py
+++ b/paimon-python/pypaimon/tests/projection_predicate_index_test.py
@@ -334,6 +334,18 @@ class RewritePredicateIndicesUnitTest(unittest.TestCase):
self.assertFalse(predicate_supports_arrow_filter(unsafe))
self.assertFalse(predicate_supports_arrow_filter(mixed))
+ def test_arrow_filter_support_rejects_large_boolean_expression(self):
+ from pypaimon.read.push_down_utils import
predicate_supports_arrow_filter
+
+ pb = self._build_predicate()
+ large = pb.or_predicates([
+ pb.equal('c', value) for value in range(257)
+ ])
+ large_in = pb.is_in('c', list(range(2000)))
+
+ self.assertFalse(predicate_supports_arrow_filter(large))
+ self.assertTrue(predicate_supports_arrow_filter(large_in))
+
def test_missing_first_row_id_materializes_null_row_ids(self):
from pypaimon.read.reader.data_file_batch_reader import
DataFileBatchReader
from pypaimon.table.special_fields import SpecialFields
diff --git a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
index d75dbf4a39..e0a35e1740 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -3245,6 +3245,55 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
plan.table.table_schema.id,
)
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_large_or_condition_pushes_down_predicate(self):
+ target = self._create_table()
+ self._write(
+ target,
+ pa.Table.from_pydict(
+ {
+ 'id': pa.array([0, 1, 2], type=pa.int32()),
+ 'name': ['Alice', 'Alice', 'Alice'],
+ 'age': pa.array([10, 11, 12], type=pa.int32()),
+ },
+ schema=self.pa_schema,
+ ),
+ )
+ condition = "t.name = 'Alice' AND ({})".format(
+ ' OR '.join(
+ '(t.id = {} AND t.age = {})'.format(i, i + 10)
+ for i in range(2000)
+ )
+ )
+
+ result, plan = self._merge_and_capture_self_merge_plan(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'age': lit(99)}, condition=condition,
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 3)
+ self.assertEqual(self._read_sorted(target)['age'], [99, 99, 99])
+ predicate = plan.predicate
+ self.assertEqual(predicate.method, 'and')
+ self.assertEqual(
+ (predicate.literals[0].field,
+ predicate.literals[0].literals),
+ ('name', ['Alice']),
+ )
+ large_or = predicate.literals[1]
+ self.assertEqual(large_or.method, 'or')
+ self.assertEqual(len(large_or.literals), 2000)
+ self.assertTrue(all(
+ child.method == 'and' and len(child.literals) == 2
+ for child in large_or.literals
+ ))
+
@unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
def test_self_merge_pushdown_handles_evolved_file_groups(self):
from pypaimon.schema.data_types import AtomicType
diff --git a/paimon-python/pypaimon/tests/reader_append_only_test.py
b/paimon-python/pypaimon/tests/reader_append_only_test.py
index 3988359ad9..480cd4ebbe 100644
--- a/paimon-python/pypaimon/tests/reader_append_only_test.py
+++ b/paimon-python/pypaimon/tests/reader_append_only_test.py
@@ -809,6 +809,26 @@ class AoReaderTest(unittest.TestCase):
])
self.assertEqual(actual.sort_by('user_id'), expected)
+ def test_ao_reader_with_large_filter(self):
+ schema = Schema.from_pyarrow_schema(self.pa_schema,
partition_keys=['dt'])
+ self.catalog.create_table('default.test_append_only_large_filter',
schema, False)
+ table = self.catalog.get_table('default.test_append_only_large_filter')
+ self._write_test_table(table)
+
+ predicate_builder = table.new_read_builder().new_predicate_builder()
+ predicate = predicate_builder.or_predicates([
+ predicate_builder.equal('user_id', value)
+ for value in list(range(100, 355)) + [2, 6]
+ ])
+ read_builder = table.new_read_builder().with_filter(predicate)
+
+ actual = self._read_test_table(read_builder).sort_by('user_id')
+ expected = pa.concat_tables([
+ self.expected.slice(1, 1),
+ self.expected.slice(5, 1),
+ ])
+ self.assertEqual(actual, expected)
+
def test_ao_reader_with_projection(self):
schema = Schema.from_pyarrow_schema(self.pa_schema,
partition_keys=['dt'])
self.catalog.create_table('default.test_append_only_projection',
schema, False)