Skip to content

Commit 0a7325a

Browse files
Ayush PatelAyush Patel
authored andcommitted
feat: Add merge() to Table and Transaction
Atomic delete-insert merge by join columns using per-column In filters for file pruning and in-memory anti-join for row-level correctness, committed as a single OVERWRITE snapshot. Unlike upsert(), does not enforce uniqueness on source or target.
1 parent 753d00e commit 0a7325a

3 files changed

Lines changed: 1006 additions & 0 deletions

File tree

pyiceberg/table/__init__.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,126 @@ def upsert(
885885

886886
return UpsertResult(rows_updated=update_row_cnt, rows_inserted=insert_row_cnt)
887887

888+
def merge(
889+
self,
890+
df: pa.Table,
891+
join_cols: List[str],
892+
snapshot_properties: Dict[str, str] = EMPTY_DICT,
893+
branch: Optional[str] = MAIN_BRANCH,
894+
enforce_unique_keys: bool = False,
895+
) -> None:
896+
"""Atomic delete-insert merge by join columns.
897+
898+
Deletes all target rows matching the source data's join column
899+
values and inserts the source rows, all in a single OVERWRITE
900+
snapshot.
901+
902+
Uses per-column ``In`` filters for file pruning (O(sum of
903+
cardinalities) instead of O(product)), then an in-memory
904+
anti-join for row-level correctness.
905+
906+
Unlike ``upsert()``, does not enforce uniqueness on source or
907+
target by default.
908+
909+
Args:
910+
df: The Arrow dataframe containing replacement rows.
911+
join_cols: Columns used to match source rows against target rows.
912+
snapshot_properties: Custom properties to be added to the snapshot summary.
913+
branch: Branch reference to run the operation.
914+
enforce_unique_keys: If True, raise ValueError when the source
915+
data contains duplicate rows based on the join columns.
916+
"""
917+
try:
918+
import pyarrow as pa
919+
import pyarrow.compute as pc
920+
except ModuleNotFoundError as e:
921+
raise ModuleNotFoundError("For writes PyArrow needs to be installed") from e
922+
923+
import functools
924+
925+
from pyiceberg.expressions import In
926+
from pyiceberg.io.pyarrow import ArrowScan, _check_pyarrow_schema_compatible, _dataframe_to_data_files
927+
from pyiceberg.table import upsert_util
928+
929+
if not isinstance(df, pa.Table):
930+
raise ValueError(f"Expected PyArrow table, got: {df}")
931+
932+
if not join_cols:
933+
raise ValueError("join_cols must be a non-empty list of column names.")
934+
935+
missing = set(join_cols) - set(df.column_names)
936+
if missing:
937+
raise ValueError(f"join_cols not found in source data: {missing}")
938+
939+
if df.num_rows == 0:
940+
return
941+
942+
downcast_ns = Config().get_bool(DOWNCAST_NS_TIMESTAMP_TO_US_ON_WRITE) or False
943+
_check_pyarrow_schema_compatible(
944+
self.table_metadata.schema(),
945+
provided_schema=df.schema,
946+
downcast_ns_timestamp_to_us=downcast_ns,
947+
format_version=self.table_metadata.format_version,
948+
)
949+
950+
# Step 1: Build per-column In filters for file pruning.
951+
# O(sum of cardinalities) instead of O(product).
952+
# Over-approximates the match set, which is fine - row-level
953+
# correctness is enforced by the anti-join in step 3.
954+
in_filters: list[BooleanExpression] = [In(col, pc.unique(df[col]).to_pylist()) for col in join_cols]
955+
candidate_filter: BooleanExpression = functools.reduce(And, in_filters)
956+
957+
# Step 2: Find candidate files via manifest pruning.
958+
scan = self._scan(row_filter=candidate_filter, case_sensitive=True)
959+
if branch is not None and branch in self.table_metadata.refs:
960+
scan = scan.use_ref(branch)
961+
tasks = list(scan.plan_files())
962+
963+
if not tasks:
964+
# No files overlap - just append.
965+
self.append(df, snapshot_properties=snapshot_properties, branch=branch)
966+
return
967+
968+
# Step 3: Read ALL rows from candidate files, anti-join to keep
969+
# non-matching rows. The candidate_filter was only for file
970+
# pruning - row-level correctness comes from the anti-join.
971+
arrow_scan = ArrowScan(
972+
self.table_metadata,
973+
self._table.io,
974+
projected_schema=self.table_metadata.schema(),
975+
row_filter=ALWAYS_TRUE,
976+
case_sensitive=True,
977+
)
978+
target_data = arrow_scan.to_table(tasks)
979+
source_keys = df.select(join_cols)
980+
981+
# Cardinality check: do multiple source rows match the same
982+
# target row? Mirrors Spark's write.merge.cardinality-check.
983+
if enforce_unique_keys:
984+
target_keys = target_data.select(join_cols)
985+
matches = target_keys.join(source_keys, keys=join_cols, join_type="inner")
986+
if upsert_util.has_duplicate_rows(matches, join_cols):
987+
raise ValueError(
988+
"Cardinality violation: multiple source rows match the same target row "
989+
"based on join columns. Set enforce_unique_keys=False to allow this."
990+
)
991+
992+
kept_rows = target_data.join(source_keys, keys=join_cols, join_type="left anti")
993+
new_content = pa.concat_tables([kept_rows, df], promote_options="default")
994+
995+
# Step 4: Atomic single-snapshot commit.
996+
# Delete old files, append rewritten content.
997+
with self.update_snapshot(snapshot_properties=snapshot_properties, branch=branch).overwrite() as overwrite_op:
998+
for task in tasks:
999+
overwrite_op.delete_data_file(task.file)
1000+
for data_file in _dataframe_to_data_files(
1001+
table_metadata=self.table_metadata,
1002+
df=new_content,
1003+
io=self._table.io,
1004+
write_uuid=overwrite_op.commit_uuid,
1005+
):
1006+
overwrite_op.append_data_file(data_file)
1007+
8881008
def add_files(
8891009
self, file_paths: List[str], snapshot_properties: Dict[str, str] = EMPTY_DICT, check_duplicate_files: bool = True
8901010
) -> None:
@@ -1415,6 +1535,36 @@ def upsert(
14151535
branch=branch,
14161536
)
14171537

1538+
def merge(
1539+
self,
1540+
df: pa.Table,
1541+
join_cols: List[str],
1542+
snapshot_properties: Dict[str, str] = EMPTY_DICT,
1543+
branch: Optional[str] = MAIN_BRANCH,
1544+
enforce_unique_keys: bool = False,
1545+
) -> None:
1546+
"""Atomic delete-insert merge by join columns.
1547+
1548+
Unlike ``upsert()``, does not enforce uniqueness on source or
1549+
target by default.
1550+
1551+
Args:
1552+
df: The Arrow dataframe containing replacement rows.
1553+
join_cols: Columns used to match source rows against target rows.
1554+
snapshot_properties: Custom properties to be added to the snapshot summary.
1555+
branch: Branch reference to run the operation.
1556+
enforce_unique_keys: If True, raise ValueError when the source
1557+
data contains duplicate rows based on the join columns.
1558+
"""
1559+
with self.transaction() as tx:
1560+
tx.merge(
1561+
df=df,
1562+
join_cols=join_cols,
1563+
snapshot_properties=snapshot_properties,
1564+
branch=branch,
1565+
enforce_unique_keys=enforce_unique_keys,
1566+
)
1567+
14181568
def append(self, df: pa.Table, snapshot_properties: Dict[str, str] = EMPTY_DICT, branch: Optional[str] = MAIN_BRANCH) -> None:
14191569
"""
14201570
Shorthand API for appending a PyArrow table to the table.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""End-to-end benchmark: merge() vs create_match_filter + overwrite().
18+
19+
Compares the full write path (filter construction + file I/O + snapshot commit)
20+
between the new merge() implementation and the previous approach.
21+
22+
Usage:
23+
poetry run pytest tests/benchmark/test_merge_filter.py -v -s -m benchmark
24+
"""
25+
26+
import gc
27+
import itertools
28+
import timeit
29+
import tracemalloc
30+
from pathlib import PosixPath
31+
from typing import Any, Callable
32+
33+
import pyarrow as pa
34+
import pytest
35+
36+
from pyiceberg.catalog import Catalog
37+
from pyiceberg.exceptions import NoSuchTableError
38+
from pyiceberg.schema import Schema
39+
from pyiceberg.table import Table
40+
from pyiceberg.table.upsert_util import create_match_filter
41+
from pyiceberg.types import IntegerType, NestedField, StringType
42+
from tests.catalog.test_base import InMemoryCatalog
43+
44+
45+
def _make_schema(col_cardinalities: dict[str, int]) -> Schema:
46+
fields = []
47+
for i, col in enumerate(col_cardinalities):
48+
field_type = IntegerType() if col == "date_id" else StringType()
49+
fields.append(NestedField(i + 1, col, field_type, required=True))
50+
fields.append(NestedField(len(col_cardinalities) + 1, "v", IntegerType(), required=True))
51+
return Schema(*fields)
52+
53+
54+
def _build_table(col_cardinalities: dict[str, int]) -> tuple[pa.Table, list[str], Schema]:
55+
from pyiceberg.io.pyarrow import schema_to_pyarrow
56+
57+
schema = _make_schema(col_cardinalities)
58+
arrow_schema = schema_to_pyarrow(schema)
59+
60+
vals: list[list[Any]] = []
61+
for col, card in col_cardinalities.items():
62+
if col == "date_id":
63+
vals.append(list(range(20260101, 20260101 + card)))
64+
else:
65+
vals.append([f"{col}_{i}" for i in range(card)])
66+
combos = list(itertools.product(*vals))
67+
data = {col: [c[i] for c in combos] for i, col in enumerate(col_cardinalities)}
68+
data["v"] = list(range(len(combos)))
69+
return pa.table(data, schema=arrow_schema), list(col_cardinalities.keys()), schema
70+
71+
72+
def _fresh_table(catalog: Catalog, name: str, schema: Schema, data: pa.Table) -> Table:
73+
ident = f"default.{name}"
74+
try:
75+
catalog.drop_table(ident)
76+
except NoSuchTableError:
77+
pass
78+
tbl = catalog.create_table(ident, schema=schema)
79+
tbl.append(data)
80+
return tbl
81+
82+
83+
def _measure(fn: Callable[[], Any], runs: int = 3) -> tuple[float, int]:
84+
"""Returns (avg_seconds, peak_memory_bytes)."""
85+
times = []
86+
peak = 0
87+
for _ in range(runs):
88+
gc.collect()
89+
tracemalloc.start()
90+
t0 = timeit.default_timer()
91+
fn()
92+
times.append(timeit.default_timer() - t0)
93+
_, p = tracemalloc.get_traced_memory()
94+
tracemalloc.stop()
95+
peak = max(peak, p)
96+
return sum(times) / len(times), peak
97+
98+
99+
def _fmt(secs: float, mem_bytes: int) -> str:
100+
mem = f"{mem_bytes / 1024:.0f} KB" if mem_bytes < 1048576 else f"{mem_bytes / 1048576:.1f} MB"
101+
return f"{secs * 1000:.0f} ms, peak {mem}"
102+
103+
104+
COLS = {"date_id": 252, "account": 100} # 25,200 target rows
105+
106+
107+
@pytest.mark.benchmark
108+
@pytest.mark.parametrize("n_source", [100, 5000])
109+
def test_e2e_merge(n_source: int, tmp_path: PosixPath) -> None:
110+
"""End-to-end merge(): per-column In + anti-join + single OVERWRITE snapshot."""
111+
target_data, join_cols, schema = _build_table(COLS)
112+
113+
catalog = InMemoryCatalog("bench", warehouse=str(tmp_path))
114+
catalog.create_namespace("default")
115+
tbl = _fresh_table(catalog, f"merge_{n_source}", schema, target_data)
116+
117+
source_dict = {col: target_data.column(col).to_pylist()[:n_source] for col in target_data.column_names}
118+
source_dict["v"] = [x + 9000 for x in source_dict["v"]]
119+
source = pa.table(source_dict, schema=target_data.schema)
120+
121+
avg, peak = _measure(lambda: tbl.merge(source, join_cols=join_cols))
122+
print(f"\n merge(): {target_data.num_rows:,} target, {n_source:,} source -> {_fmt(avg, peak)}")
123+
124+
125+
@pytest.mark.benchmark
126+
def test_e2e_overwrite_100src(tmp_path: PosixPath) -> None:
127+
"""End-to-end overwrite() with 100 source rows."""
128+
target_data, join_cols, schema = _build_table(COLS)
129+
130+
catalog = InMemoryCatalog("bench", warehouse=str(tmp_path))
131+
catalog.create_namespace("default")
132+
tbl = _fresh_table(catalog, "overwrite_100", schema, target_data)
133+
134+
source_dict = {col: target_data.column(col).to_pylist()[:100] for col in target_data.column_names}
135+
source_dict["v"] = [x + 9000 for x in source_dict["v"]]
136+
source = pa.table(source_dict, schema=target_data.schema)
137+
138+
avg, peak = _measure(lambda: (tbl.overwrite(source, overwrite_filter=create_match_filter(source, join_cols))))
139+
print(f"\n overwrite(): {target_data.num_rows:,} target, 100 source -> {_fmt(avg, peak)}")
140+
141+
142+
@pytest.mark.benchmark
143+
def test_e2e_overwrite_5ksrc_filter_only(tmp_path: PosixPath) -> None:
144+
"""At 5,000 source rows, just constructing the filter takes seconds.
145+
146+
We only measure filter construction here because the full overwrite()
147+
with a 20,000-node expression tree causes process termination during
148+
manifest evaluation.
149+
"""
150+
target_data, join_cols, schema = _build_table(COLS)
151+
152+
source_dict = {col: target_data.column(col).to_pylist()[:5000] for col in target_data.column_names}
153+
source_dict["v"] = [x + 9000 for x in source_dict["v"]]
154+
source = pa.table(source_dict, schema=target_data.schema)
155+
156+
avg, peak = _measure(lambda: create_match_filter(source, join_cols), runs=1)
157+
print(f"\n create_match_filter only (no overwrite): 5,000 source -> {_fmt(avg, peak)}")

0 commit comments

Comments
 (0)