Skip to content

Commit fbcc6fd

Browse files
committed
Keep the upsert match filter flat for composite keys
An upsert with two or more join columns built one Or disjunct per key. PyArrow flattens that chain and overflows its stack from about 1,000 keys. The scan, the insert filter and the overwrite filter all crashed. Build one In per join column instead. Do the exact key match in Arrow with an anti join. Append back the rows that the overwrite filter removes but does not update. Closes #3508
1 parent ebbc0ba commit fbcc6fd

3 files changed

Lines changed: 112 additions & 44 deletions

File tree

‎pyiceberg/table/__init__.py‎

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,6 @@ def upsert(
895895
except ModuleNotFoundError as e:
896896
raise ModuleNotFoundError("For writes PyArrow needs to be installed") from e
897897

898-
from pyiceberg.io.pyarrow import expression_to_pyarrow
899898
from pyiceberg.table import upsert_util
900899

901900
if join_cols is None:
@@ -926,25 +925,16 @@ def upsert(
926925
format_version=self.table_metadata.format_version,
927926
)
928927

929-
# get list of rows that exist so we don't have to load the entire target table
930-
matched_predicate = upsert_util.create_match_filter(df, join_cols)
931-
932928
# We must use Transaction.table_metadata for the scan. This includes all uncommitted - but relevant - changes.
929+
def scan_matching(row_filter: BooleanExpression) -> DataScan:
930+
matching_scan = self._scan(row_filter=row_filter, case_sensitive=case_sensitive)
931+
return matching_scan.use_ref(branch) if branch in self.table_metadata.refs else matching_scan
933932

934-
matched_iceberg_record_batches_scan = DataScan(
935-
table_metadata=self.table_metadata,
936-
io=self._table.io,
937-
row_filter=matched_predicate,
938-
case_sensitive=case_sensitive,
939-
)
940-
941-
if branch in self.table_metadata.refs:
942-
matched_iceberg_record_batches_scan = matched_iceberg_record_batches_scan.use_ref(branch)
943-
944-
matched_iceberg_record_batches = matched_iceberg_record_batches_scan.to_arrow_batch_reader()
933+
# get list of rows that exist so we don't have to load the entire target table
934+
# The match filter can select more rows than the keys in df, get_rows_to_update and exclude_keys are exact
935+
matched_iceberg_record_batches = scan_matching(upsert_util.create_match_filter(df, join_cols)).to_arrow_batch_reader()
945936

946937
batches_to_overwrite = []
947-
overwrite_predicates = []
948938
rows_to_insert = df
949939

950940
for batch in matched_iceberg_record_batches:
@@ -958,33 +948,35 @@ def upsert(
958948
rows_to_update = upsert_util.get_rows_to_update(df, rows, join_cols)
959949

960950
if len(rows_to_update) > 0:
961-
# build the match predicate filter
962-
overwrite_mask_predicate = upsert_util.create_match_filter(rows_to_update, join_cols)
963-
964951
batches_to_overwrite.append(rows_to_update)
965-
overwrite_predicates.append(overwrite_mask_predicate)
966952

967953
if when_not_matched_insert_all:
968-
expr_match = upsert_util.create_match_filter(rows, join_cols)
969-
expr_match_bound = bind(self.table_metadata.schema(), expr_match, case_sensitive=case_sensitive)
970-
expr_match_arrow = expression_to_pyarrow(expr_match_bound)
971-
972-
# Filter rows per batch.
973-
rows_to_insert = rows_to_insert.filter(~expr_match_arrow)
954+
rows_to_insert = upsert_util.exclude_keys(rows_to_insert, rows, join_cols)
974955

975956
update_row_cnt = 0
976957
insert_row_cnt = 0
977958

978959
if batches_to_overwrite:
979960
rows_to_update = pa.concat_tables(batches_to_overwrite)
980961
update_row_cnt = len(rows_to_update)
962+
overwrite_filter = upsert_util.create_match_filter(rows_to_update, join_cols)
963+
964+
# For a composite key the overwrite filter can remove rows outside the updated keys, write those back unchanged
965+
unchanged_rows = None
966+
if len(join_cols) > 1:
967+
removed_rows = scan_matching(overwrite_filter).to_arrow()
968+
unchanged_rows = upsert_util.exclude_keys(removed_rows, rows_to_update, join_cols)
969+
981970
self.overwrite(
982971
rows_to_update,
983-
overwrite_filter=Or(*overwrite_predicates) if len(overwrite_predicates) > 1 else overwrite_predicates[0],
972+
overwrite_filter=overwrite_filter,
984973
branch=branch,
985974
snapshot_properties=snapshot_properties,
986975
)
987976

977+
if unchanged_rows is not None and len(unchanged_rows) > 0:
978+
self.append(unchanged_rows, branch=branch, snapshot_properties=snapshot_properties)
979+
988980
if when_not_matched_insert_all:
989981
insert_row_cnt = len(rows_to_insert)
990982
if rows_to_insert:

‎pyiceberg/table/upsert_util.py‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,33 @@
2222
from pyarrow import compute as pc
2323

2424
from pyiceberg.expressions import (
25-
AlwaysFalse,
2625
BooleanExpression,
27-
EqualTo,
2826
In,
29-
Or,
3027
)
3128

3229

3330
def create_match_filter(df: pyarrow_table, join_cols: list[str]) -> BooleanExpression:
31+
"""
32+
Return a filter that matches every row whose key is present in the given table.
33+
34+
The filter is one In per join column, so it stays flat for any number of keys. For a composite key
35+
it can also match rows whose key is not present, use exclude_keys to get the exact set afterwards.
36+
See: https://github.com/apache/iceberg-python/issues/3508
37+
"""
3438
unique_keys = df.select(join_cols).group_by(join_cols).aggregate([])
3539

36-
if len(join_cols) == 1:
37-
return In(join_cols[0], unique_keys[0].to_pylist())
38-
else:
39-
filters = [
40-
functools.reduce(operator.and_, [EqualTo(col, row[col]) for col in join_cols]) for row in unique_keys.to_pylist()
41-
]
42-
43-
if len(filters) == 0:
44-
return AlwaysFalse()
45-
elif len(filters) == 1:
46-
return filters[0]
47-
else:
48-
return Or(*filters)
40+
return functools.reduce(operator.and_, [In(col, unique_keys[col].to_pylist()) for col in join_cols])
41+
42+
43+
def exclude_keys(table: pa.Table, keys: pa.Table, join_cols: list[str]) -> pa.Table:
44+
"""Return the rows of the table whose key is not present in the given keys, in the original order."""
45+
INDEX_COLUMN_NAME = "__index"
46+
47+
table_keys = table.select(join_cols)
48+
index = table_keys.append_column(INDEX_COLUMN_NAME, pa.array(range(len(table))))
49+
remaining = index.join(keys.select(join_cols).cast(table_keys.schema), keys=join_cols, join_type="left anti")
50+
51+
return table.take(remaining.sort_by(INDEX_COLUMN_NAME)[INDEX_COLUMN_NAME])
4952

5053

5154
def has_duplicate_rows(df: pyarrow_table, join_cols: list[str]) -> bool:

‎tests/table/test_upsert.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from pyiceberg.catalog import Catalog
2626
from pyiceberg.exceptions import NoSuchTableError
27-
from pyiceberg.expressions import AlwaysTrue, And, EqualTo, Reference
27+
from pyiceberg.expressions import AlwaysTrue, And, EqualTo, In, Reference
2828
from pyiceberg.expressions.literals import LongLiteral
2929
from pyiceberg.io.pyarrow import schema_to_pyarrow
3030
from pyiceberg.partitioning import PartitionField, PartitionSpec
@@ -927,3 +927,76 @@ def test_upsert_snapshot_properties(catalog: Catalog) -> None:
927927
for snapshot in snapshots[initial_snapshot_count:]:
928928
assert snapshot.summary is not None
929929
assert snapshot.summary.additional_properties.get("test_prop") == "test_value"
930+
931+
932+
def test_create_match_filter_composite_key_is_flat() -> None:
933+
"""
934+
Test create_match_filter with a composite key and several unique keys.
935+
Expected: One In per key column, not one Or disjunct per key.
936+
"""
937+
schema = pa.schema([pa.field("order_id", pa.int32()), pa.field("order_line_id", pa.int32())])
938+
table = pa.Table.from_pylist([{"order_id": 101, "order_line_id": 1}, {"order_id": 102, "order_line_id": 2}], schema=schema)
939+
expr = create_match_filter(table, ["order_id", "order_line_id"])
940+
assert expr == And(In("order_id", [101, 102]), In("order_line_id", [1, 2]))
941+
942+
943+
def _composite_key_table(catalog: Catalog, identifier: str) -> tuple[Table, pa.Schema]:
944+
_drop_table(catalog, identifier)
945+
schema = Schema(
946+
NestedField(1, "k1", IntegerType(), required=True),
947+
NestedField(2, "k2", StringType(), required=True),
948+
NestedField(3, "v", IntegerType(), required=True),
949+
identifier_field_ids=[1, 2],
950+
)
951+
arrow_schema = pa.schema(
952+
[
953+
pa.field("k1", pa.int32(), nullable=False),
954+
pa.field("k2", pa.string(), nullable=False),
955+
pa.field("v", pa.int32(), nullable=False),
956+
]
957+
)
958+
return catalog.create_table(identifier, schema=schema), arrow_schema
959+
960+
961+
def test_upsert_composite_key_keeps_rows_outside_source_keys(catalog: Catalog) -> None:
962+
# Every k1 and every k2 of the source exists in the target, but only two of the key tuples do
963+
tbl, arrow_schema = _composite_key_table(catalog, "default.test_upsert_composite_key_keeps_rows_outside_source_keys")
964+
tbl.append(
965+
pa.Table.from_pylist(
966+
[
967+
{"k1": 1, "k2": "a", "v": 1},
968+
{"k1": 1, "k2": "b", "v": 2},
969+
{"k1": 2, "k2": "a", "v": 3},
970+
{"k1": 2, "k2": "b", "v": 4},
971+
],
972+
schema=arrow_schema,
973+
)
974+
)
975+
source = pa.Table.from_pylist(
976+
[{"k1": 1, "k2": "b", "v": 20}, {"k1": 2, "k2": "a", "v": 30}, {"k1": 2, "k2": "c", "v": 5}], schema=arrow_schema
977+
)
978+
979+
res = tbl.upsert(source)
980+
981+
assert (res.rows_updated, res.rows_inserted) == (2, 1)
982+
assert sorted(tbl.scan().to_arrow().to_pylist(), key=lambda r: (r["k1"], r["k2"])) == [
983+
{"k1": 1, "k2": "a", "v": 1},
984+
{"k1": 1, "k2": "b", "v": 20},
985+
{"k1": 2, "k2": "a", "v": 30},
986+
{"k1": 2, "k2": "b", "v": 4},
987+
{"k1": 2, "k2": "c", "v": 5},
988+
]
989+
990+
991+
def test_upsert_composite_key_large_batch(catalog: Catalog) -> None:
992+
# One Or disjunct per key tuple overflows the Arrow expression stack from about 1,000 tuples (#3508)
993+
tbl, arrow_schema = _composite_key_table(catalog, "default.test_upsert_composite_key_large_batch")
994+
tbl.append(pa.Table.from_pylist([{"k1": i, "k2": f"k{i}", "v": i} for i in range(5000)], schema=arrow_schema))
995+
source = pa.Table.from_pylist([{"k1": i, "k2": f"k{i}", "v": i + 10} for i in range(2500, 7500)], schema=arrow_schema)
996+
997+
res = tbl.upsert(source)
998+
999+
assert (res.rows_updated, res.rows_inserted) == (2500, 2500)
1000+
assert sorted(tbl.scan().to_arrow().to_pylist(), key=lambda r: r["k1"]) == [
1001+
{"k1": i, "k2": f"k{i}", "v": i if i < 2500 else i + 10} for i in range(7500)
1002+
]

0 commit comments

Comments
 (0)