Skip to content

Commit ffd7eba

Browse files
committed
Fix filter pushdown when task.residual is AlwaysTrue (REST catalog)
When REST catalog returns residual_filter=None, it becomes AlwaysTrue in FileScanTask.from_rest_response(). The old code used task.residual for pushdown, which meant no filter was applied when residual was AlwaysTrue. Fix: Introduce _resolve_pushdown_filter() helper that: 1. Returns AlwaysTrue if row_filter is AlwaysTrue (no filter requested) 2. Uses task.residual if non-trivial (handles schema evolution column renames) 3. Binds and returns row_filter when task.residual is AlwaysTrue This ensures the filter is always applied, either via pushdown at read time or via post-filter for delete paths. Added regression tests: - TestResolvePushdownFilter: unit tests for the helper function - TestPlainReadWithAlwaysTrueResidual: integration test for the full flow - Updated test_scan_applies_filter_via_pushdown_when_residual_is_always_true
1 parent de2d2f0 commit ffd7eba

2 files changed

Lines changed: 201 additions & 15 deletions

File tree

pyiceberg/execution/_orchestrate.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,55 @@ def _get_pos_delete_threshold() -> int:
7272
return get_execution_config_int("pos-delete-threshold", _POS_DELETE_THRESHOLD_DEFAULT)
7373

7474

75+
def _resolve_pushdown_filter(
76+
row_filter: BooleanExpression,
77+
task_residual: BooleanExpression,
78+
projected_schema: Schema,
79+
case_sensitive: bool,
80+
) -> BooleanExpression:
81+
"""Determine the filter to push down to the Parquet scanner.
82+
83+
Filter selection priority:
84+
1. If row_filter is AlwaysTrue → push AlwaysTrue (no filter requested)
85+
2. If task.residual is non-trivial → use it (handles schema evolution column renames)
86+
3. Otherwise → bind and return row_filter (e.g., REST server returned residual_filter=None)
87+
88+
The task.residual comes from the scan planner and has column names adjusted for
89+
schema evolution (old column names that match the file). When REST server-side
90+
planning is used, the server may return residual_filter=None which becomes
91+
AlwaysTrue, so we fall back to the original row_filter in that case.
92+
93+
Args:
94+
row_filter: The original row filter from the scan (possibly unbound).
95+
task_residual: The residual filter from the FileScanTask (usually bound).
96+
projected_schema: The projected schema to bind unbound filters against.
97+
case_sensitive: Whether to use case-sensitive column matching when binding.
98+
99+
Returns:
100+
The filter expression to push down to the Parquet scanner. Returns AlwaysTrue
101+
if the filter cannot be bound (e.g., references columns not in the schema).
102+
"""
103+
if isinstance(row_filter, AlwaysTrue):
104+
return AlwaysTrue()
105+
elif not isinstance(task_residual, AlwaysTrue):
106+
return task_residual
107+
else:
108+
# Fall back to row_filter: must bind it to the schema first.
109+
# The row_filter may be unbound (e.g., GreaterThan("id", 2)) and expression_to_pyarrow
110+
# requires bound expressions. Binding converts UnboundPredicate to BoundPredicate
111+
# with proper field references.
112+
from pyiceberg.expressions.visitors import bind
113+
114+
try:
115+
return bind(projected_schema, row_filter, case_sensitive)
116+
except (TypeError, ValueError):
117+
# TypeError: filter is already bound
118+
# ValueError: filter references columns not in the schema
119+
# In either case, return the filter as-is and let the scanner handle it
120+
# (or fall back to post-filter if scanner can't apply it)
121+
return row_filter
122+
123+
75124
#: Sentinel object returned by _build_reconcile_fn when the batch's schema already
76125
#: matches the projected schema (no reconciliation needed). Distinct from a callable
77126
#: to avoid the overhead of an identity-function call on every batch in the common case.
@@ -267,9 +316,7 @@ def _execute_task(task: FileScanTask) -> list[pa.RecordBatch]:
267316
UserWarning,
268317
stacklevel=2,
269318
)
270-
# Use task.residual for pushdown (handles schema evolution column names).
271-
# If row_filter is AlwaysTrue, caller wants all rows - use AlwaysTrue for pushdown too.
272-
pushdown_filter = AlwaysTrue() if isinstance(row_filter, AlwaysTrue) else task.residual
319+
pushdown_filter = _resolve_pushdown_filter(row_filter, task.residual, projected_schema, case_sensitive)
273320
batches = backends.read.read_parquet(
274321
task.file.file_path,
275322
projected_schema,
@@ -282,7 +329,7 @@ def _execute_task(task: FileScanTask) -> list[pa.RecordBatch]:
282329
# equality_ids present but all referenced columns dropped via schema
283330
# evolution. _get_equality_field_names already emitted a warning.
284331
# Skip anti-join; fall through to plain read (superset of correct results).
285-
pushdown_filter = AlwaysTrue() if isinstance(row_filter, AlwaysTrue) else task.residual
332+
pushdown_filter = _resolve_pushdown_filter(row_filter, task.residual, projected_schema, case_sensitive)
286333
batches = backends.read.read_parquet(
287334
task.file.file_path,
288335
projected_schema,
@@ -309,9 +356,8 @@ def _execute_task(task: FileScanTask) -> list[pa.RecordBatch]:
309356
)
310357
# Note: filter not pushed down in positional delete path
311358
else:
312-
# Use task.residual for pushdown (handles schema evolution column names).
313-
# If row_filter is AlwaysTrue, caller wants all rows - use AlwaysTrue for pushdown too.
314-
pushdown_filter = AlwaysTrue() if isinstance(row_filter, AlwaysTrue) else task.residual
359+
# Plain read path: push down filter to scanner for efficiency.
360+
pushdown_filter = _resolve_pushdown_filter(row_filter, task.residual, projected_schema, case_sensitive)
315361
batches = backends.read.read_parquet(
316362
task.file.file_path,
317363
projected_schema,

tests/execution/test_orchestrate.py

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -423,18 +423,27 @@ def test_scan_calls_both_pos_and_eq_for_combined_deletes(
423423
result = pa.Table.from_batches(batches)
424424
assert sorted(result.column("id").to_pylist()) == [2, 3, 5]
425425

426-
def test_scan_calls_filter_for_residual(self, tmp_path: Path, schema: Schema, observable_backends: Backends) -> None:
427-
"""orchestrate_scan calls ComputeBackend.filter when row_filter has non-trivial predicate."""
426+
def test_scan_applies_filter_via_pushdown_when_residual_is_always_true(
427+
self, tmp_path: Path, schema: Schema, observable_backends: Backends
428+
) -> None:
429+
"""orchestrate_scan pushes down filter when task.residual is AlwaysTrue.
430+
431+
This tests the fix for REST catalogs returning residual_filter=None:
432+
- task.residual becomes AlwaysTrue
433+
- row_filter should be bound and pushed down to the scanner
434+
- Post-filter (ComputeBackend.filter) should NOT be called
435+
- Result should still be correct (filter applied via pushdown)
436+
"""
428437
from pyiceberg.execution._orchestrate import orchestrate_scan
429438
from pyiceberg.expressions.visitors import bind
430439

431440
data_path = str(tmp_path / "data.parquet")
432441
pq.write_table(pa.table({"id": [1, 2, 3, 4, 5], "name": ["a", "b", "c", "d", "e"]}), data_path)
433442

434-
# Create a BOUND predicate (expression_to_pyarrow requires bound predicates)
443+
# Create a BOUND predicate
435444
bound_filter = bind(schema, EqualTo("id", 3), case_sensitive=True)
436445

437-
# Task with AlwaysTrue residual (pushdown handles the filter)
446+
# Task with AlwaysTrue residual (simulating REST catalog)
438447
task = FileScanTask(
439448
data_file=DataFile.from_args(
440449
content=DataFileContent.DATA,
@@ -461,14 +470,14 @@ def test_scan_calls_filter_for_residual(self, tmp_path: Path, schema: Schema, ob
461470
)
462471
)
463472

464-
# BEHAVIORAL PROOF: filter was called with the row_filter
473+
# With the fix: filter is pushed down to scanner, post-filter is NOT called
465474
compute_backend = _get_observable_compute(observable_backends)
466475
filter_calls = [c for c in compute_backend.calls if c["method"] == "filter"]
467-
assert len(filter_calls) == 1
476+
assert len(filter_calls) == 0, "Post-filter should not be called when filter is pushed down"
468477

469-
# Verify correct result
478+
# Verify correct result - filter was applied via pushdown
470479
result = pa.Table.from_batches(batches)
471-
assert result.column("id").to_pylist() == [3]
480+
assert result.column("id").to_pylist() == [3], "Filter should select only id=3"
472481

473482

474483
class TestToArrowDispatchesThroughBackends:
@@ -1325,3 +1334,134 @@ def test_empty_manifests_yields_no_tasks(self, tmp_path: Path) -> None:
13251334
)
13261335

13271336
assert tasks == []
1337+
1338+
1339+
class TestResolvePushdownFilter:
1340+
"""Test _resolve_pushdown_filter helper for correct filter selection.
1341+
1342+
This function determines which filter to push down to the Parquet scanner:
1343+
1. If row_filter is AlwaysTrue → use AlwaysTrue (no filter)
1344+
2. If task.residual is non-trivial → use it (handles schema evolution)
1345+
3. Otherwise → bind and return row_filter (REST server returned residual_filter=None)
1346+
"""
1347+
1348+
def test_always_true_row_filter_returns_always_true(self) -> None:
1349+
"""When row_filter is AlwaysTrue, pushdown should be AlwaysTrue."""
1350+
from pyiceberg.execution._orchestrate import _resolve_pushdown_filter
1351+
from pyiceberg.expressions import AlwaysTrue, GreaterThan
1352+
1353+
schema = Schema(
1354+
NestedField(1, "id", IntegerType(), required=True),
1355+
)
1356+
result = _resolve_pushdown_filter(AlwaysTrue(), GreaterThan("id", 5), schema, case_sensitive=True)
1357+
assert isinstance(result, AlwaysTrue)
1358+
1359+
def test_non_trivial_residual_is_used(self) -> None:
1360+
"""When task.residual is non-trivial, it should be used (schema evolution case)."""
1361+
from pyiceberg.execution._orchestrate import _resolve_pushdown_filter
1362+
from pyiceberg.expressions import GreaterThan
1363+
1364+
schema = Schema(
1365+
NestedField(1, "id", IntegerType(), required=True),
1366+
)
1367+
row_filter = GreaterThan("new_col_name", 5)
1368+
task_residual = GreaterThan("old_col_name", 5) # Schema evolution renamed column
1369+
1370+
result = _resolve_pushdown_filter(row_filter, task_residual, schema, case_sensitive=True)
1371+
assert result is task_residual
1372+
1373+
def test_always_true_residual_falls_back_to_bound_row_filter(self) -> None:
1374+
"""When task.residual is AlwaysTrue, bind and return row_filter.
1375+
1376+
This is the critical case for REST catalog: when the server returns
1377+
residual_filter=None, it becomes AlwaysTrue, and we should bind the
1378+
original row_filter and use it for pushdown instead of losing the filter entirely.
1379+
"""
1380+
from pyiceberg.execution._orchestrate import _resolve_pushdown_filter
1381+
from pyiceberg.expressions import AlwaysTrue, BoundGreaterThan, GreaterThan
1382+
1383+
schema = Schema(
1384+
NestedField(1, "id", IntegerType(), required=True),
1385+
)
1386+
row_filter = GreaterThan("id", 2)
1387+
task_residual = AlwaysTrue() # REST server returned residual_filter=None
1388+
1389+
result = _resolve_pushdown_filter(row_filter, task_residual, schema, case_sensitive=True)
1390+
# Should be a bound expression now
1391+
assert isinstance(result, BoundGreaterThan)
1392+
assert not isinstance(result, AlwaysTrue)
1393+
1394+
1395+
class TestPlainReadWithAlwaysTrueResidual:
1396+
"""Regression test: plain read path must apply filter when task.residual is AlwaysTrue.
1397+
1398+
Bug scenario (fixed in this PR):
1399+
- User creates unpartitioned table via REST catalog
1400+
- User deletes rows (CoW)
1401+
- User scans with row_filter
1402+
- REST server returns residual_filter=None → task.residual=AlwaysTrue
1403+
- OLD BUG: pushdown_filter = task.residual = AlwaysTrue (filter lost!)
1404+
- FIX: fall back to row_filter when task.residual is AlwaysTrue
1405+
"""
1406+
1407+
def test_filter_applied_when_residual_is_always_true(self, tmp_path: Path) -> None:
1408+
"""Plain read with AlwaysTrue residual but non-trivial row_filter must filter rows."""
1409+
# Write a data file with rows [1, 2, 3, 4, 5]
1410+
data_schema = pa.schema([pa.field("id", pa.int32()), pa.field("category", pa.string())])
1411+
data_table = pa.table({"id": [1, 2, 3, 4, 5], "category": ["a", "a", "a", "a", "a"]}, schema=data_schema)
1412+
data_path = str(tmp_path / "data.parquet")
1413+
pq.write_table(data_table, data_path)
1414+
1415+
# Create a FileScanTask with AlwaysTrue residual (simulating REST server)
1416+
from pyiceberg.expressions import GreaterThan
1417+
from pyiceberg.manifest import DataFile, DataFileContent, FileFormat
1418+
1419+
data_file = DataFile.from_args(
1420+
content=DataFileContent.DATA,
1421+
file_path=data_path,
1422+
file_format=FileFormat.PARQUET,
1423+
partition={},
1424+
record_count=5,
1425+
file_size_in_bytes=1000,
1426+
)
1427+
1428+
task = FileScanTask(
1429+
data_file=data_file,
1430+
delete_files=None,
1431+
residual=AlwaysTrue(), # REST server returned residual_filter=None
1432+
)
1433+
1434+
# Create mock scan objects
1435+
schema = Schema(
1436+
NestedField(1, "id", IntegerType(), required=True),
1437+
NestedField(2, "category", StringType(), required=False),
1438+
)
1439+
mock_metadata = MagicMock()
1440+
mock_metadata.schema.return_value = schema
1441+
mock_metadata.format_version = 2
1442+
mock_metadata.name_mapping.return_value = None
1443+
mock_metadata.schemas = [schema]
1444+
1445+
# Run orchestrate_scan with a row_filter that should filter out id <= 2
1446+
from pyiceberg.execution._orchestrate import orchestrate_scan
1447+
from pyiceberg.execution.protocol import Backends
1448+
1449+
backends = Backends.resolve({})
1450+
row_filter = GreaterThan("id", 2)
1451+
1452+
result_batches = list(
1453+
orchestrate_scan(
1454+
backends=backends,
1455+
tasks=iter([task]),
1456+
table_metadata=mock_metadata,
1457+
projected_schema=schema,
1458+
row_filter=row_filter,
1459+
case_sensitive=True,
1460+
)
1461+
)
1462+
1463+
result = pa.Table.from_batches(result_batches)
1464+
# Should only have rows where id > 2: [3, 4, 5]
1465+
assert sorted(result.column("id").to_pylist()) == [3, 4, 5], (
1466+
f"Filter should exclude id <= 2, got {result.column('id').to_pylist()}"
1467+
)

0 commit comments

Comments
 (0)