Skip to content

Commit 3692011

Browse files
committed
fix(upsert): use bounded file match filter to avoid scan recursion
1 parent d99e463 commit 3692011

3 files changed

Lines changed: 179 additions & 4 deletions

File tree

pyiceberg/table/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -874,8 +874,8 @@ def upsert(
874874
format_version=self.table_metadata.format_version,
875875
)
876876

877-
# get list of rows that exist so we don't have to load the entire target table
878-
matched_predicate = upsert_util.create_match_filter(df, join_cols)
877+
# get list of candidate rows without building one predicate node per source key
878+
matched_predicate = upsert_util.create_file_match_filter(df, join_cols)
879879

880880
# We must use Transaction.table_metadata for the scan. This includes all uncommitted - but relevant - changes.
881881

pyiceberg/table/upsert_util.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,41 @@
2525
AlwaysFalse,
2626
BooleanExpression,
2727
EqualTo,
28+
GreaterThanOrEqual,
2829
In,
30+
IsNull,
31+
LessThanOrEqual,
2932
Or,
3033
)
3134

3235

36+
def create_file_match_filter(df: pyarrow_table, join_cols: list[str]) -> BooleanExpression:
37+
"""Build a conservative predicate for upsert file pruning.
38+
39+
The returned predicate may match extra files, but must not exclude files that
40+
could contain a matching row. Exact row matching still happens downstream.
41+
"""
42+
if len(df) == 0:
43+
return AlwaysFalse()
44+
45+
per_col: list[BooleanExpression] = []
46+
for col in join_cols:
47+
col_arr = df.column(col)
48+
bounds = pc.min_max(col_arr).as_py()
49+
col_min, col_max = bounds["min"], bounds["max"]
50+
51+
if col_min is None:
52+
per_col.append(IsNull(col))
53+
continue
54+
55+
pred: BooleanExpression = GreaterThanOrEqual(col, col_min) & LessThanOrEqual(col, col_max)
56+
if pc.any(pc.is_null(col_arr)).as_py():
57+
pred = pred | IsNull(col)
58+
per_col.append(pred)
59+
60+
return functools.reduce(operator.and_, per_col)
61+
62+
3363
def create_match_filter(df: pyarrow_table, join_cols: list[str]) -> BooleanExpression:
3464
unique_keys = df.select(join_cols).group_by(join_cols).aggregate([])
3565

tests/table/test_upsert.py

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
# KIND, either express or implied. See the License for the
1515
# specific language governing permissions and limitations
1616
# under the License.
17+
import subprocess
18+
import sys
19+
import textwrap
1720
from pathlib import PosixPath
1821

1922
import pyarrow as pa
@@ -23,13 +26,23 @@
2326

2427
from pyiceberg.catalog import Catalog
2528
from pyiceberg.exceptions import NoSuchTableError
26-
from pyiceberg.expressions import AlwaysTrue, And, EqualTo, Reference
29+
from pyiceberg.expressions import (
30+
AlwaysFalse,
31+
AlwaysTrue,
32+
And,
33+
EqualTo,
34+
GreaterThanOrEqual,
35+
IsNull,
36+
LessThanOrEqual,
37+
Or,
38+
Reference,
39+
)
2740
from pyiceberg.expressions.literals import LongLiteral
2841
from pyiceberg.io.pyarrow import schema_to_pyarrow
2942
from pyiceberg.schema import Schema
3043
from pyiceberg.table import Table, UpsertResult
3144
from pyiceberg.table.snapshots import Operation
32-
from pyiceberg.table.upsert_util import create_match_filter
45+
from pyiceberg.table.upsert_util import create_file_match_filter, create_match_filter
3346
from pyiceberg.types import IntegerType, NestedField, StringType, StructType
3447
from tests.catalog.test_base import InMemoryCatalog
3548

@@ -443,6 +456,138 @@ def test_create_match_filter_single_condition() -> None:
443456
)
444457

445458

459+
def test_create_file_match_filter_empty_source_prunes_everything() -> None:
460+
table = pa.table({"order_id": pa.array([], type=pa.int64()), "order_line_id": pa.array([], type=pa.int64())})
461+
462+
assert create_file_match_filter(table, ["order_id", "order_line_id"]) == AlwaysFalse()
463+
464+
465+
def test_create_file_match_filter_multi_column_bounds() -> None:
466+
table = pa.table({"order_id": [1, 2, 3], "order_line_id": [100, 200, 300]})
467+
468+
expr = create_file_match_filter(table, ["order_id", "order_line_id"])
469+
470+
leaves: list[object] = []
471+
472+
def collect(node: object) -> None:
473+
if isinstance(node, And):
474+
collect(node.left)
475+
collect(node.right)
476+
else:
477+
leaves.append(node)
478+
479+
collect(expr)
480+
bounds_by_col: dict[str, dict[type, object]] = {}
481+
for leaf in leaves:
482+
bounds_by_col.setdefault(leaf.term.name, {})[type(leaf)] = leaf.literal.value # type: ignore[attr-defined]
483+
484+
assert bounds_by_col == {
485+
"order_id": {GreaterThanOrEqual: 1, LessThanOrEqual: 3},
486+
"order_line_id": {GreaterThanOrEqual: 100, LessThanOrEqual: 300},
487+
}
488+
489+
490+
@pytest.mark.parametrize(
491+
("values", "expected"),
492+
[
493+
([1, 5], And(GreaterThanOrEqual("order_id", 1), LessThanOrEqual("order_id", 5))),
494+
([None, None], IsNull("order_id")),
495+
([1, None, 5], Or(And(GreaterThanOrEqual("order_id", 1), LessThanOrEqual("order_id", 5)), IsNull("order_id"))),
496+
],
497+
)
498+
def test_create_file_match_filter_null_shape(values: list[int | None], expected: object) -> None:
499+
table = pa.table({"order_id": pa.array(values, type=pa.int64())})
500+
501+
assert create_file_match_filter(table, ["order_id"]) == expected
502+
503+
504+
def test_upsert_multi_col_file_match_filter_culls_false_positives(catalog: Catalog) -> None:
505+
identifier = "default.test_upsert_multi_col_file_match_filter_culls_false_positives"
506+
_drop_table(catalog, identifier)
507+
508+
schema = pa.schema([("order_id", pa.int32()), ("order_line_id", pa.int32()), ("payload", pa.string())])
509+
table = catalog.create_table(identifier, schema)
510+
table.append(
511+
pa.Table.from_pylist(
512+
[
513+
{"order_id": 1, "order_line_id": 200, "payload": "keep-1"},
514+
{"order_id": 2, "order_line_id": 100, "payload": "keep-2"},
515+
{"order_id": 1, "order_line_id": 100, "payload": "old"},
516+
],
517+
schema=schema,
518+
)
519+
)
520+
521+
source = pa.Table.from_pylist(
522+
[
523+
{"order_id": 1, "order_line_id": 100, "payload": "new"},
524+
{"order_id": 2, "order_line_id": 200, "payload": "insert"},
525+
],
526+
schema=schema,
527+
)
528+
529+
result = table.upsert(source, join_cols=["order_id", "order_line_id"])
530+
531+
assert result.rows_updated == 1
532+
assert result.rows_inserted == 1
533+
rows_by_key = {(row["order_id"], row["order_line_id"]): row["payload"] for row in table.scan().to_arrow().to_pylist()}
534+
assert rows_by_key == {
535+
(1, 200): "keep-1",
536+
(2, 100): "keep-2",
537+
(1, 100): "new",
538+
(2, 200): "insert",
539+
}
540+
541+
542+
def test_upsert_large_composite_key_initial_scan_does_not_recurse(tmp_path: PosixPath) -> None:
543+
"""Regression: initial scan planning must not build the exact composite-key tree.
544+
545+
Running this in a subprocess keeps the test runner alive on runtimes where the
546+
old recursive visitor shape can overflow the C stack.
547+
"""
548+
script = textwrap.dedent(
549+
f"""
550+
import sys
551+
552+
sys.setrecursionlimit(10**7)
553+
554+
import pyarrow as pa
555+
556+
from tests.catalog.test_base import InMemoryCatalog
557+
558+
n = 30_000
559+
catalog = InMemoryCatalog("test", warehouse={str(tmp_path)!r})
560+
catalog.create_namespace("default")
561+
schema = pa.schema([
562+
("order_id", pa.int64()),
563+
("order_line_id", pa.int64()),
564+
("payload", pa.string()),
565+
])
566+
table = catalog.create_table("default.regression", schema)
567+
table.append(
568+
pa.Table.from_pylist(
569+
[{{"order_id": 0, "order_line_id": 100_000, "payload": "old"}}],
570+
schema=schema,
571+
)
572+
)
573+
source = pa.table({{
574+
"order_id": pa.array(range(n), type=pa.int64()),
575+
"order_line_id": pa.array(range(100_000, 100_000 + n), type=pa.int64()),
576+
"payload": pa.array(["old"] * n, type=pa.string()),
577+
}})
578+
579+
table.upsert(
580+
source,
581+
join_cols=["order_id", "order_line_id"],
582+
when_not_matched_insert_all=False,
583+
)
584+
"""
585+
)
586+
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=False)
587+
588+
assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
589+
590+
446591
def test_upsert_with_duplicate_rows_in_table(catalog: Catalog) -> None:
447592
identifier = "default.test_upsert_with_duplicate_rows_in_table"
448593

0 commit comments

Comments
 (0)