Skip to content

Commit e0b3732

Browse files
committed
Prune files on large IN predicates with a min and max check
An IN predicate with more than 200 values returned ROWS_MIGHT_MATCH before the file bounds were checked. Above that limit, plan_files kept every data file, so a delete or an upsert scanned the whole table. Keep the exact check at 200 values or fewer. Above the limit, compare only the smallest and the largest value against the file bounds. The result is always a superset of the exact check. Closes #4003 Claude-Session: https://claude.ai/code/session_01XwKs9csmvNooLVfK61qYwA
1 parent 39440e5 commit e0b3732

3 files changed

Lines changed: 121 additions & 11 deletions

File tree

‎pyiceberg/expressions/visitors.py‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ def visit_in(self, term: BoundTerm, literals: set[L]) -> bool:
575575
return ROWS_CANNOT_MATCH
576576

577577
if len(literals) > IN_PREDICATE_LIMIT:
578-
return ROWS_MIGHT_MATCH
578+
literals = {min(literals), max(literals)}
579579

580580
lower = _from_byte_buffer(term.ref().field.field_type, field.lower_bound)
581581

@@ -1388,23 +1388,26 @@ def visit_in(self, term: BoundTerm, literals: set[L]) -> bool:
13881388
if self._contains_nulls_only(field_id) or self._contains_nans_only(field_id):
13891389
return ROWS_CANNOT_MATCH
13901390

1391-
if len(literals) > IN_PREDICATE_LIMIT:
1392-
# skip evaluating the predicate if the number of values is too big
1393-
return ROWS_MIGHT_MATCH
1394-
13951391
if not isinstance(field.field_type, PrimitiveType):
13961392
raise ValueError(f"Expected PrimitiveType: {field.field_type}")
13971393

1394+
# only compare the smallest and largest values if the number of values is too big
1395+
above_limit = len(literals) > IN_PREDICATE_LIMIT
1396+
13981397
lower_bound_bytes = self.lower_bounds.get(field_id)
13991398
if lower_bound_bytes is not None:
14001399
lower_bound = from_bytes(field.field_type, lower_bound_bytes)
14011400
if self._is_nan(lower_bound):
14021401
# NaN indicates unreliable bounds. See the InclusiveMetricsEvaluator docs for more.
14031402
return ROWS_MIGHT_MATCH
14041403

1405-
literals = {lit for lit in literals if lower_bound <= lit} # type: ignore[operator]
1406-
if len(literals) == 0:
1407-
return ROWS_CANNOT_MATCH
1404+
if above_limit:
1405+
if max(literals) < lower_bound: # type: ignore[operator]
1406+
return ROWS_CANNOT_MATCH
1407+
else:
1408+
literals = {lit for lit in literals if lower_bound <= lit} # type: ignore[operator]
1409+
if len(literals) == 0:
1410+
return ROWS_CANNOT_MATCH
14081411

14091412
upper_bound_bytes = self.upper_bounds.get(field_id)
14101413
if upper_bound_bytes is not None:
@@ -1413,9 +1416,13 @@ def visit_in(self, term: BoundTerm, literals: set[L]) -> bool:
14131416
if self._is_nan(upper_bound):
14141417
return ROWS_MIGHT_MATCH
14151418

1416-
literals = {lit for lit in literals if upper_bound >= lit} # type: ignore[operator]
1417-
if len(literals) == 0:
1418-
return ROWS_CANNOT_MATCH
1419+
if above_limit:
1420+
if min(literals) > upper_bound: # type: ignore[operator]
1421+
return ROWS_CANNOT_MATCH
1422+
else:
1423+
literals = {lit for lit in literals if upper_bound >= lit} # type: ignore[operator]
1424+
if len(literals) == 0:
1425+
return ROWS_CANNOT_MATCH
14191426

14201427
return ROWS_MIGHT_MATCH
14211428

‎tests/expressions/test_evaluator.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
StartsWith,
4646
)
4747
from pyiceberg.expressions.visitors import (
48+
IN_PREDICATE_LIMIT,
4849
ROWS_CANNOT_MATCH,
4950
ROWS_MIGHT_MATCH,
5051
ROWS_MIGHT_NOT_MATCH,
@@ -674,6 +675,47 @@ def test_integer_in(schema_data_file: Schema, data_file: DataFile) -> None:
674675
assert should_read, "Should read: large in expression"
675676

676677

678+
def test_integer_in_above_limit(schema_data_file: Schema, data_file: DataFile) -> None:
679+
below_lower = set(range(INT_MIN_VALUE - IN_PREDICATE_LIMIT - 1, INT_MIN_VALUE))
680+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", below_lower)).eval(data_file)
681+
assert not should_read, "Should not read: id below lower bound (max 29 < 30)"
682+
683+
above_upper = set(range(INT_MAX_VALUE + 1, INT_MAX_VALUE + IN_PREDICATE_LIMIT + 2))
684+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", above_upper)).eval(data_file)
685+
assert not should_read, "Should not read: id above upper bound (min 80 > 79)"
686+
687+
equal_lower = set(range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MIN_VALUE + 1))
688+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", equal_lower)).eval(data_file)
689+
assert should_read, "Should read: id equal to lower bound (max 30 == 30)"
690+
691+
equal_upper = set(range(INT_MAX_VALUE, INT_MAX_VALUE + IN_PREDICATE_LIMIT + 1))
692+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", equal_upper)).eval(data_file)
693+
assert should_read, "Should read: id equal to upper bound (min 79 == 79)"
694+
695+
straddle = set(range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MAX_VALUE + 2))
696+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", straddle)).eval(data_file)
697+
assert should_read, "Should read: id range overlaps bounds"
698+
699+
outside_both = {*range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MIN_VALUE), INT_MAX_VALUE + 1}
700+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", outside_both)).eval(data_file)
701+
assert should_read, "Should read: id range covers bounds, values between min and max are not checked"
702+
703+
should_read = _InclusiveMetricsEvaluator(
704+
schema_data_file, In("all_nulls", {str(i) for i in range(IN_PREDICATE_LIMIT + 1)})
705+
).eval(data_file)
706+
assert not should_read, "Should skip: in on all nulls column"
707+
708+
709+
def test_integer_in_at_limit(schema_data_file: Schema, data_file: DataFile) -> None:
710+
below_lower = set(range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MIN_VALUE))
711+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", below_lower)).eval(data_file)
712+
assert not should_read, "Should not read: id below lower bound (max 29 < 30)"
713+
714+
outside_both = {*range(INT_MIN_VALUE - IN_PREDICATE_LIMIT + 1, INT_MIN_VALUE), INT_MAX_VALUE + 1}
715+
should_read = _InclusiveMetricsEvaluator(schema_data_file, In("id", outside_both)).eval(data_file)
716+
assert not should_read, "Should not read: no id between lower and upper bounds"
717+
718+
677719
def test_integer_not_in(schema_data_file: Schema, data_file: DataFile) -> None:
678720
should_read = _InclusiveMetricsEvaluator(schema_data_file, NotIn("id", {INT_MIN_VALUE - 25, INT_MIN_VALUE - 24})).eval(
679721
data_file
@@ -909,6 +951,34 @@ def test_inclusive_metrics_evaluator_in(schema_data_file_nan: Schema, data_file_
909951
assert should_read, "Should match: overlap with upper bounds"
910952

911953

954+
def test_inclusive_metrics_evaluator_in_above_limit(schema_data_file_nan: Schema, data_file_nan: DataFile) -> None:
955+
below_seven = {float(i) for i in range(-IN_PREDICATE_LIMIT, 1)}
956+
above_twenty_two = {float(i) for i in range(30, 30 + IN_PREDICATE_LIMIT + 1)}
957+
958+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("all_nan", below_seven)).eval(data_file_nan)
959+
assert not should_read, "Should not match: all nan column doesn't contain number"
960+
961+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("max_nan", below_seven)).eval(data_file_nan)
962+
assert not should_read, "Should not match: all values are smaller than lower bound"
963+
964+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("max_nan", above_twenty_two)).eval(data_file_nan)
965+
assert should_read, "Should match: upper bound is nan"
966+
967+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("min_max_nan", below_seven)).eval(data_file_nan)
968+
assert should_read, "Should match: no visibility"
969+
970+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("all_nan_null_bounds", below_seven)).eval(data_file_nan)
971+
assert not should_read, "Should not match: all nan column doesn't contain number"
972+
973+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("some_nan_correct_bounds", below_seven)).eval(data_file_nan)
974+
assert not should_read, "Should not match: all values are smaller than lower bound"
975+
976+
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, In("some_nan_correct_bounds", above_twenty_two)).eval(
977+
data_file_nan
978+
)
979+
assert not should_read, "Should not match: all values are larger than upper bound"
980+
981+
912982
def test_inclusive_metrics_evaluator_not_in(schema_data_file_nan: Schema, data_file_nan: DataFile) -> None:
913983
should_read = _InclusiveMetricsEvaluator(schema_data_file_nan, NotIn("all_nan", (1, 10, 30))).eval(data_file_nan)
914984
assert should_read, "Should match: no visibility"

‎tests/expressions/test_visitors.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
)
6767
from pyiceberg.expressions.literals import Literal, literal
6868
from pyiceberg.expressions.visitors import (
69+
IN_PREDICATE_LIMIT,
6970
MAX_DNF_TERMS,
7071
BindVisitor,
7172
BooleanExpressionVisitor,
@@ -1360,6 +1361,38 @@ def test_integer_in(schema: Schema, manifest: ManifestFile) -> None:
13601361
)
13611362

13621363

1364+
def test_integer_in_above_limit(schema: Schema, manifest: ManifestFile) -> None:
1365+
below_lower = range(INT_MIN_VALUE - IN_PREDICATE_LIMIT - 1, INT_MIN_VALUE)
1366+
assert not _ManifestEvalVisitor(schema, In(Reference("id"), below_lower), case_sensitive=True).eval(manifest), (
1367+
"Should not read: id below lower bound (max 29 < 30)"
1368+
)
1369+
1370+
above_upper = range(INT_MAX_VALUE + 1, INT_MAX_VALUE + IN_PREDICATE_LIMIT + 2)
1371+
assert not _ManifestEvalVisitor(schema, In(Reference("id"), above_upper), case_sensitive=True).eval(manifest), (
1372+
"Should not read: id above upper bound (min 80 > 79)"
1373+
)
1374+
1375+
equal_lower = range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MIN_VALUE + 1)
1376+
assert _ManifestEvalVisitor(schema, In(Reference("id"), equal_lower), case_sensitive=True).eval(manifest), (
1377+
"Should read: id equal to lower bound (max 30 == 30)"
1378+
)
1379+
1380+
equal_upper = range(INT_MAX_VALUE, INT_MAX_VALUE + IN_PREDICATE_LIMIT + 1)
1381+
assert _ManifestEvalVisitor(schema, In(Reference("id"), equal_upper), case_sensitive=True).eval(manifest), (
1382+
"Should read: id equal to upper bound (min 79 == 79)"
1383+
)
1384+
1385+
straddle = range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MAX_VALUE + 2)
1386+
assert _ManifestEvalVisitor(schema, In(Reference("id"), straddle), case_sensitive=True).eval(manifest), (
1387+
"Should read: id range overlaps bounds"
1388+
)
1389+
1390+
outside_both = [*range(INT_MIN_VALUE - IN_PREDICATE_LIMIT, INT_MIN_VALUE), INT_MAX_VALUE + 1]
1391+
assert _ManifestEvalVisitor(schema, In(Reference("id"), outside_both), case_sensitive=True).eval(manifest), (
1392+
"Should read: id range covers bounds"
1393+
)
1394+
1395+
13631396
def test_integer_not_in(schema: Schema, manifest: ManifestFile) -> None:
13641397
assert _ManifestEvalVisitor(
13651398
schema, NotIn(Reference("id"), (INT_MIN_VALUE - 25, INT_MIN_VALUE - 24)), case_sensitive=True

0 commit comments

Comments
 (0)