Skip to content

Commit de2d2f0

Browse files
committed
Add regression tests for positional deletes + row_filter (ea07a19)
1 parent ea07a19 commit de2d2f0

1 file changed

Lines changed: 375 additions & 0 deletions

File tree

tests/execution/test_positional_deletes.py

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,3 +1331,378 @@ def test_cow_delete_skips_empty_record_count_files(self, tmp_path: Path) -> None
13311331
# Verify the guard: with record_count=0, the file is skipped
13321332
# The guard fires before the threshold check
13331333
assert original_row_count == 0 # Would hit `continue` in the loop
1334+
1335+
1336+
# =============================================================================
1337+
# Test: Positional Deletes + Row Filter (regression test for ea07a19b)
1338+
# =============================================================================
1339+
1340+
1341+
class TestPositionalDeletesWithRowFilter:
1342+
"""Regression tests for positional deletes combined with row_filter.
1343+
1344+
These tests verify the fix in commit ea07a19b: the post-filter must be applied
1345+
when positional deletes are present, because the positional delete path does NOT
1346+
push down the row_filter.
1347+
1348+
The bug in commit 2b82fb96 used task.residual to infer whether pushdown happened,
1349+
which was incorrect. task.residual is set by the planner and doesn't reflect
1350+
whether the orchestrator actually pushed down the filter during read.
1351+
"""
1352+
1353+
@pytest.fixture
1354+
def wide_data_file(self, tmp_path: Path) -> str:
1355+
"""Write a data file with 100 rows: id=[1..100]."""
1356+
path = str(tmp_path / "wide_data.parquet")
1357+
table = pa.table({"id": list(range(1, 101)), "name": [f"row_{i}" for i in range(1, 101)]})
1358+
pq.write_table(table, path)
1359+
return path
1360+
1361+
@pytest.fixture
1362+
def pos_delete_for_wide(self, tmp_path: Path, wide_data_file: str) -> str:
1363+
"""Position delete file: removes rows at positions 0,1,2,3 (id=1,2,3,4)."""
1364+
path = str(tmp_path / "pos_delete_wide.parquet")
1365+
table = pa.table(
1366+
{
1367+
"file_path": [wide_data_file] * 4,
1368+
"pos": pa.array([0, 1, 2, 3], type=pa.int64()),
1369+
}
1370+
)
1371+
pq.write_table(table, path)
1372+
return path
1373+
1374+
def test_positional_deletes_with_row_filter_applies_both(
1375+
self,
1376+
wide_data_file: str,
1377+
pos_delete_for_wide: str,
1378+
tmp_path: Path,
1379+
) -> None:
1380+
"""Positional deletes + row_filter: both must be applied.
1381+
1382+
This is the exact scenario that failed in CI (test_read_multiple_batches_in_task_with_position_deletes):
1383+
- Data file has many rows
1384+
- Position deletes remove some rows (by position)
1385+
- row_filter should further filter the surviving rows
1386+
1387+
The bug was: post-filter was skipped because task.residual != AlwaysTrue,
1388+
but positional delete paths don't push down the filter.
1389+
"""
1390+
from pyiceberg.expressions import LessThanOrEqual
1391+
from pyiceberg.expressions.visitors import bind
1392+
1393+
schema = Schema(
1394+
NestedField(field_id=1, name="id", field_type=IntegerType(), required=True),
1395+
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
1396+
)
1397+
1398+
backends = Backends(
1399+
read=PyArrowReadBackend(),
1400+
write=MagicMock(),
1401+
compute=PyArrowComputeBackend(),
1402+
io_properties={},
1403+
)
1404+
1405+
metadata = MagicMock()
1406+
metadata.schema.return_value = schema
1407+
metadata.format_version = 2
1408+
1409+
data_file = DataFile.from_args(
1410+
content=DataFileContent.DATA,
1411+
file_path=wide_data_file,
1412+
file_format=FileFormat.PARQUET,
1413+
record_count=100,
1414+
file_size_in_bytes=5000,
1415+
)
1416+
pos_delete_file = DataFile.from_args(
1417+
content=DataFileContent.POSITION_DELETES,
1418+
file_path=pos_delete_for_wide,
1419+
file_format=FileFormat.PARQUET,
1420+
record_count=4,
1421+
file_size_in_bytes=500,
1422+
)
1423+
1424+
# Create a task with a non-trivial residual (simulating what the planner would set)
1425+
# The key bug was: task.residual != AlwaysTrue caused post-filter to be skipped
1426+
row_filter = LessThanOrEqual("id", 50)
1427+
bound_filter = bind(schema, row_filter, case_sensitive=True)
1428+
1429+
task = FileScanTask(
1430+
data_file=data_file,
1431+
delete_files={pos_delete_file},
1432+
residual=bound_filter, # Non-trivial residual (this was the bug trigger)
1433+
)
1434+
1435+
results = list(
1436+
orchestrate_scan(
1437+
backends=backends,
1438+
tasks=iter([task]),
1439+
table_metadata=metadata,
1440+
projected_schema=schema,
1441+
row_filter=bound_filter,
1442+
case_sensitive=True,
1443+
)
1444+
)
1445+
1446+
result_table = pa.Table.from_batches(results)
1447+
surviving_ids = sorted(result_table.column("id").to_pylist())
1448+
1449+
# Position deletes: remove positions 0,1,2,3 → remove id=1,2,3,4
1450+
# Row filter: keep only id <= 50
1451+
# Expected: [5, 6, 7, ..., 50] = 46 rows
1452+
expected_ids = list(range(5, 51))
1453+
1454+
assert surviving_ids == expected_ids, (
1455+
f"Expected {len(expected_ids)} rows (5-50) after positional deletes (remove 1-4) and "
1456+
f"row_filter (id <= 50). Got {len(surviving_ids)} rows: {surviving_ids[:10]}..."
1457+
)
1458+
1459+
def test_positional_deletes_with_row_filter_always_true_residual(
1460+
self,
1461+
wide_data_file: str,
1462+
pos_delete_for_wide: str,
1463+
tmp_path: Path,
1464+
) -> None:
1465+
"""Positional deletes + row_filter with AlwaysTrue residual: filter still applied.
1466+
1467+
Even when task.residual is AlwaysTrue, if row_filter is non-trivial,
1468+
the post-filter must still run for positional delete paths.
1469+
"""
1470+
from pyiceberg.expressions import LessThanOrEqual
1471+
from pyiceberg.expressions.visitors import bind
1472+
1473+
schema = Schema(
1474+
NestedField(field_id=1, name="id", field_type=IntegerType(), required=True),
1475+
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
1476+
)
1477+
1478+
backends = Backends(
1479+
read=PyArrowReadBackend(),
1480+
write=MagicMock(),
1481+
compute=PyArrowComputeBackend(),
1482+
io_properties={},
1483+
)
1484+
1485+
metadata = MagicMock()
1486+
metadata.schema.return_value = schema
1487+
metadata.format_version = 2
1488+
1489+
data_file = DataFile.from_args(
1490+
content=DataFileContent.DATA,
1491+
file_path=wide_data_file,
1492+
file_format=FileFormat.PARQUET,
1493+
record_count=100,
1494+
file_size_in_bytes=5000,
1495+
)
1496+
pos_delete_file = DataFile.from_args(
1497+
content=DataFileContent.POSITION_DELETES,
1498+
file_path=pos_delete_for_wide,
1499+
file_format=FileFormat.PARQUET,
1500+
record_count=4,
1501+
file_size_in_bytes=500,
1502+
)
1503+
1504+
row_filter = LessThanOrEqual("id", 50)
1505+
bound_filter = bind(schema, row_filter, case_sensitive=True)
1506+
1507+
# Task with AlwaysTrue residual
1508+
task = FileScanTask(
1509+
data_file=data_file,
1510+
delete_files={pos_delete_file},
1511+
residual=AlwaysTrue(), # AlwaysTrue residual
1512+
)
1513+
1514+
results = list(
1515+
orchestrate_scan(
1516+
backends=backends,
1517+
tasks=iter([task]),
1518+
table_metadata=metadata,
1519+
projected_schema=schema,
1520+
row_filter=bound_filter, # But row_filter is non-trivial
1521+
case_sensitive=True,
1522+
)
1523+
)
1524+
1525+
result_table = pa.Table.from_batches(results)
1526+
surviving_ids = sorted(result_table.column("id").to_pylist())
1527+
1528+
# Expected: [5, 6, 7, ..., 50] = 46 rows
1529+
expected_ids = list(range(5, 51))
1530+
assert surviving_ids == expected_ids
1531+
1532+
def test_equality_deletes_with_row_filter(self, tmp_path: Path) -> None:
1533+
"""Equality deletes + row_filter: both must be applied.
1534+
1535+
Similar to positional deletes, equality delete paths (anti_join_from_files)
1536+
don't push down the filter, so post-filter is needed.
1537+
"""
1538+
from pyiceberg.expressions import LessThanOrEqual
1539+
from pyiceberg.expressions.visitors import bind
1540+
1541+
schema = Schema(
1542+
NestedField(field_id=1, name="id", field_type=IntegerType(), required=True),
1543+
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
1544+
)
1545+
1546+
backends = Backends(
1547+
read=PyArrowReadBackend(),
1548+
write=MagicMock(),
1549+
compute=PyArrowComputeBackend(),
1550+
io_properties={},
1551+
)
1552+
1553+
metadata = MagicMock()
1554+
metadata.schema.return_value = schema
1555+
metadata.format_version = 2
1556+
1557+
# Data file: id=[1..100]
1558+
data_path = str(tmp_path / "eq_data.parquet")
1559+
pq.write_table(
1560+
pa.table({"id": list(range(1, 101)), "name": [f"row_{i}" for i in range(1, 101)]}),
1561+
data_path,
1562+
)
1563+
1564+
# Equality delete: remove id=1,2,3,4
1565+
eq_path = str(tmp_path / "eq_delete.parquet")
1566+
pq.write_table(pa.table({"id": [1, 2, 3, 4]}), eq_path)
1567+
1568+
data_file = DataFile.from_args(
1569+
content=DataFileContent.DATA,
1570+
file_path=data_path,
1571+
file_format=FileFormat.PARQUET,
1572+
record_count=100,
1573+
file_size_in_bytes=5000,
1574+
)
1575+
eq_delete_file = DataFile.from_args(
1576+
content=DataFileContent.EQUALITY_DELETES,
1577+
file_path=eq_path,
1578+
file_format=FileFormat.PARQUET,
1579+
record_count=4,
1580+
file_size_in_bytes=500,
1581+
equality_ids=[1], # field_id=1 is "id"
1582+
)
1583+
1584+
row_filter = LessThanOrEqual("id", 50)
1585+
bound_filter = bind(schema, row_filter, case_sensitive=True)
1586+
1587+
task = FileScanTask(
1588+
data_file=data_file,
1589+
delete_files={eq_delete_file},
1590+
residual=bound_filter,
1591+
)
1592+
1593+
results = list(
1594+
orchestrate_scan(
1595+
backends=backends,
1596+
tasks=iter([task]),
1597+
table_metadata=metadata,
1598+
projected_schema=schema,
1599+
row_filter=bound_filter,
1600+
case_sensitive=True,
1601+
)
1602+
)
1603+
1604+
result_table = pa.Table.from_batches(results)
1605+
surviving_ids = sorted(result_table.column("id").to_pylist())
1606+
1607+
# Equality deletes: remove id=1,2,3,4
1608+
# Row filter: keep only id <= 50
1609+
# Expected: [5, 6, 7, ..., 50] = 46 rows
1610+
expected_ids = list(range(5, 51))
1611+
assert surviving_ids == expected_ids
1612+
1613+
def test_combined_deletes_with_row_filter(self, tmp_path: Path) -> None:
1614+
"""Combined positional + equality deletes + row_filter: all must be applied."""
1615+
from pyiceberg.expressions import LessThanOrEqual
1616+
from pyiceberg.expressions.visitors import bind
1617+
1618+
schema = Schema(
1619+
NestedField(field_id=1, name="id", field_type=IntegerType(), required=True),
1620+
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
1621+
)
1622+
1623+
backends = Backends(
1624+
read=PyArrowReadBackend(),
1625+
write=MagicMock(),
1626+
compute=PyArrowComputeBackend(),
1627+
io_properties={},
1628+
)
1629+
1630+
metadata = MagicMock()
1631+
metadata.schema.return_value = schema
1632+
metadata.format_version = 2
1633+
1634+
# Data file: id=[1..100]
1635+
data_path = str(tmp_path / "combined_data.parquet")
1636+
pq.write_table(
1637+
pa.table({"id": list(range(1, 101)), "name": [f"row_{i}" for i in range(1, 101)]}),
1638+
data_path,
1639+
)
1640+
1641+
# Position delete: remove positions 0,1 (id=1,2)
1642+
pos_path = str(tmp_path / "combined_pos.parquet")
1643+
pq.write_table(
1644+
pa.table(
1645+
{
1646+
"file_path": [data_path, data_path],
1647+
"pos": pa.array([0, 1], type=pa.int64()),
1648+
}
1649+
),
1650+
pos_path,
1651+
)
1652+
1653+
# Equality delete: remove id=3,4
1654+
eq_path = str(tmp_path / "combined_eq.parquet")
1655+
pq.write_table(pa.table({"id": [3, 4]}), eq_path)
1656+
1657+
data_file = DataFile.from_args(
1658+
content=DataFileContent.DATA,
1659+
file_path=data_path,
1660+
file_format=FileFormat.PARQUET,
1661+
record_count=100,
1662+
file_size_in_bytes=5000,
1663+
)
1664+
pos_delete_file = DataFile.from_args(
1665+
content=DataFileContent.POSITION_DELETES,
1666+
file_path=pos_path,
1667+
file_format=FileFormat.PARQUET,
1668+
record_count=2,
1669+
file_size_in_bytes=200,
1670+
)
1671+
eq_delete_file = DataFile.from_args(
1672+
content=DataFileContent.EQUALITY_DELETES,
1673+
file_path=eq_path,
1674+
file_format=FileFormat.PARQUET,
1675+
record_count=2,
1676+
file_size_in_bytes=200,
1677+
equality_ids=[1],
1678+
)
1679+
1680+
row_filter = LessThanOrEqual("id", 50)
1681+
bound_filter = bind(schema, row_filter, case_sensitive=True)
1682+
1683+
task = FileScanTask(
1684+
data_file=data_file,
1685+
delete_files={pos_delete_file, eq_delete_file},
1686+
residual=bound_filter,
1687+
)
1688+
1689+
results = list(
1690+
orchestrate_scan(
1691+
backends=backends,
1692+
tasks=iter([task]),
1693+
table_metadata=metadata,
1694+
projected_schema=schema,
1695+
row_filter=bound_filter,
1696+
case_sensitive=True,
1697+
)
1698+
)
1699+
1700+
result_table = pa.Table.from_batches(results)
1701+
surviving_ids = sorted(result_table.column("id").to_pylist())
1702+
1703+
# Position deletes: remove positions 0,1 → remove id=1,2
1704+
# Equality deletes: remove id=3,4
1705+
# Row filter: keep only id <= 50
1706+
# Expected: [5, 6, 7, ..., 50] = 46 rows
1707+
expected_ids = list(range(5, 51))
1708+
assert surviving_ids == expected_ids

0 commit comments

Comments
 (0)