blob: 4981b8187f8131f91fe96d4197b079618ed8ab38 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Any, Dict, List, Optional, Tuple
import pyarrow as pa
from pypaimon.read.table_read import TableRead
from pypaimon.table.row.blob import Blob
from pypaimon.table.row.internal_row import InternalRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.row_utils import require_columns, row_to_named_values
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_write import StreamTableWrite
# Composite key is represented as a tuple of values
_KeyTuple = Tuple[Any, ...]
logger = logging.getLogger(__name__)
class TableUpsertByKey:
"""
Table upsert by one or more user-specified key columns for append-only tables.
For each row in the input Arrow table:
- If one or more rows with the same upsert_keys composite value already exist →
update all of them (in-place rewrite).
- If no matching row exists → append as a new row.
All upsert_keys must be columns present in both the input data and the table schema.
"""
def __init__(self, table, commit_user: str, commit_identifier: int):
from pypaimon.table.file_store_table import FileStoreTable
self.table: FileStoreTable = table
self.commit_user = commit_user
self.commit_identifier = commit_identifier
def upsert(self, data: pa.Table, upsert_keys: List[str],
update_cols: Optional[List[str]] = None) -> List[CommitMessage]:
"""
Upsert rows into an append-only table by the specified key columns.
Execution is driven partition-by-partition:
1. Group input rows by their partition values.
2. For each partition, scan only that partition to build the
key -> _ROW_ID map.
3. Split the partition's input rows into matched (update) and
unmatched (append).
4. Perform updates and appends.
This keeps memory usage proportional to a single partition's key set
and avoids scanning the entire table.
Args:
data: Input Arrow table containing rows to upsert.
Must contain all upsert_keys columns.
upsert_keys: One or more column names used together as a composite
match key.
update_cols: Columns to update for matched rows.
If None, all columns in the table schema are updated.
Returns:
List of CommitMessages to be committed.
"""
self._validate_inputs(data, upsert_keys, update_cols)
# Determine which columns to update
if update_cols is None or len(update_cols) == len(self.table.field_names):
effective_update_cols = None # means all columns
else:
effective_update_cols = update_cols
all_commit_messages: List[CommitMessage] = []
# Process each partition independently
for partition_spec, partition_data in self._group_by_partition(data):
msgs = self._upsert_partition(
partition_data, upsert_keys, partition_spec, effective_update_cols
)
all_commit_messages.extend(msgs)
return all_commit_messages
def upsert_rows(self, rows, upsert_keys: List[str],
update_cols: Optional[List[str]] = None) -> List[CommitMessage]:
row_list = self._normalize_rows(rows)
if not row_list:
raise ValueError("rows must not be empty.")
row_items = [
(row, row_to_named_values(row, self.table.table_schema.fields))
for row in row_list
]
for _, values_by_name in row_items:
self._validate_row_inputs(values_by_name, upsert_keys, update_cols)
if update_cols is None or len(update_cols) == len(self.table.field_names):
effective_update_cols = None
else:
effective_update_cols = update_cols
commit_messages: List[CommitMessage] = []
for partition_spec, partition_items in self._group_rows_by_partition(row_items):
commit_messages.extend(
self._upsert_row_partition(
partition_items,
upsert_keys,
partition_spec,
effective_update_cols,
)
)
return commit_messages
@staticmethod
def _normalize_rows(rows) -> List:
if isinstance(rows, InternalRow):
return [rows]
return list(rows)
def _group_rows_by_partition(
self,
row_items: List[Tuple[Any, Dict[str, Any]]],
) -> List[Tuple[Dict[str, Any], List[Tuple[Any, Dict[str, Any]]]]]:
if not self.table.partition_keys:
return [({}, row_items)]
partition_to_items: Dict[Tuple[Any, ...], List[Tuple[Any, Dict[str, Any]]]] = {}
for item in row_items:
_, values_by_name = item
part_tuple = tuple(
values_by_name[key] for key in self.table.partition_keys
)
partition_to_items.setdefault(part_tuple, []).append(item)
return [
(dict(zip(self.table.partition_keys, part_tuple)), items)
for part_tuple, items in partition_to_items.items()
]
def _upsert_row_partition(
self,
row_items: List[Tuple[Any, Dict[str, Any]]],
upsert_keys: List[str],
partition_spec: Dict[str, Any],
update_cols: Optional[List[str]],
) -> List[CommitMessage]:
partition_key_set = set(self.table.partition_keys)
match_keys = [k for k in upsert_keys if k not in partition_key_set]
input_key_tuples = [
tuple(values_by_name[k] for k in match_keys)
for _, values_by_name in row_items
]
for key_tuple in input_key_tuples:
for value in key_tuple:
if isinstance(value, Blob):
raise ValueError("Blob values are not supported as upsert keys.")
row_items, input_key_tuples = self._dedup_row_items_last_write_wins(
row_items, input_key_tuples, partition_spec)
key_to_row_ids = self._build_key_to_row_ids_map(
match_keys, partition_spec, set(input_key_tuples)
)
matched_items: List[Tuple[Any, Dict[str, Any]]] = []
matched_row_ids: List[List[int]] = []
new_items: List[Tuple[Any, Dict[str, Any]]] = []
for item, key_tuple in zip(row_items, input_key_tuples):
if key_tuple in key_to_row_ids:
matched_items.append(item)
matched_row_ids.append(key_to_row_ids[key_tuple])
else:
new_items.append(item)
commit_messages: List[CommitMessage] = []
if matched_items:
cols_to_update = (
list(update_cols)
if update_cols is not None
else list(self.table.field_names)
)
for _, values_by_name in matched_items:
require_columns(values_by_name, cols_to_update, "upsert_by_key")
commit_messages.extend(TableUpdateByRowId(
self.table, self.commit_user, self.commit_identifier,
).update_rows_columns(
[row for row, _ in matched_items],
matched_row_ids,
cols_to_update,
))
if new_items:
commit_messages.extend(self._append_rows(new_items))
return commit_messages
@staticmethod
def _dedup_row_items_last_write_wins(
row_items: List[Tuple[Any, Dict[str, Any]]],
input_key_tuples: List[_KeyTuple],
partition_spec: Dict[str, Any],
) -> Tuple[List[Tuple[Any, Dict[str, Any]]], List[_KeyTuple]]:
key_to_last_idx: Dict[_KeyTuple, int] = {}
for i, key_tuple in enumerate(input_key_tuples):
key_to_last_idx[key_tuple] = i
if len(key_to_last_idx) == len(input_key_tuples):
return row_items, input_key_tuples
original_count = len(input_key_tuples)
dedup_indices = sorted(key_to_last_idx.values())
logger.warning(
"Deduplicated input rows from %d to %d in partition %s "
"(kept last occurrence).",
original_count, len(dedup_indices), partition_spec,
)
return (
[row_items[i] for i in dedup_indices],
[input_key_tuples[i] for i in dedup_indices],
)
# ------------------------------------------------------------------
# Partition grouping
# ------------------------------------------------------------------
def _group_by_partition(
self, data: pa.Table
) -> List[Tuple[Dict[str, Any], pa.Table]]:
"""
Split *data* into ``(partition_spec, partition_rows)`` pairs.
For non-partitioned tables a single pair ``({}, data)`` is returned.
Iteration order matches first-appearance order of each partition value
in the input — dict preserves insertion order on Python 3.7+.
"""
partition_keys = self.table.partition_keys
if not partition_keys:
return [({}, data)]
part_columns = [data[k].to_pylist() for k in partition_keys]
partition_to_indices: Dict[Tuple[Any, ...], List[int]] = {}
for i in range(data.num_rows):
part_tuple = tuple(col[i] for col in part_columns)
partition_to_indices.setdefault(part_tuple, []).append(i)
return [
(
dict(zip(partition_keys, part_tuple)),
data.take(pa.array(indices, type=pa.int64())),
)
for part_tuple, indices in partition_to_indices.items()
]
# ------------------------------------------------------------------
# Per-partition upsert
# ------------------------------------------------------------------
def _upsert_partition(
self,
partition_data: pa.Table,
upsert_keys: List[str],
partition_spec: Dict[str, Any],
update_cols: Optional[List[str]],
) -> List[CommitMessage]:
"""
Full upsert cycle scoped to a single partition.
Partition key columns are stripped from *upsert_keys* before matching
because all rows within this partition share the same partition values.
The scan reads partition data in batches and filters against the input
key set on-the-fly, so only matching key → _ROW_ID pairs are kept in
memory (instead of the entire partition's key set).
"""
# Partition columns are constant inside one partition – drop them
# from the key set used for matching.
partition_key_set = set(self.table.partition_keys)
match_keys = [k for k in upsert_keys if k not in partition_key_set]
# 1. Build the composite key tuple for every input row.
key_columns = [partition_data[k].to_pylist() for k in match_keys]
input_key_tuples: List[_KeyTuple] = [
tuple(col[i] for col in key_columns)
for i in range(partition_data.num_rows)
]
# 2. Deduplicate – keep the LAST occurrence of every repeated key.
partition_data, input_key_tuples = self._dedup_last_write_wins(
partition_data, input_key_tuples, partition_spec,
)
# 3. Scan partition once, keeping key → [_ROW_ID, ...] for keys that
# appear in the input (memory ∝ matched existing rows, not the
# whole partition).
key_to_row_ids = self._build_key_to_row_ids_map(
match_keys, partition_spec, set(input_key_tuples),
)
# 4. Partition input rows into matched (update) vs unmatched (append).
matched_indices: List[int] = []
new_indices: List[int] = []
for i, key_tuple in enumerate(input_key_tuples):
(matched_indices if key_tuple in key_to_row_ids else new_indices).append(i)
logger.info(
"Upserting partition %s: %d matched, %d new",
partition_spec, len(matched_indices), len(new_indices),
)
total_updates = sum(
len(key_to_row_ids[input_key_tuples[i]]) for i in matched_indices)
if total_updates > len(matched_indices):
logger.info(
"Upsert fan-out in partition %s: %d input rows expand to "
"%d row updates", partition_spec,
len(matched_indices), total_updates,
)
commit_messages: List[CommitMessage] = []
if matched_indices:
commit_messages.extend(self._do_updates(
partition_data, matched_indices,
input_key_tuples, key_to_row_ids, update_cols,
))
if new_indices:
commit_messages.extend(self._do_appends(partition_data, new_indices))
return commit_messages
@staticmethod
def _dedup_last_write_wins(
partition_data: pa.Table,
input_key_tuples: List[_KeyTuple],
partition_spec: Dict[str, Any],
) -> Tuple[pa.Table, List[_KeyTuple]]:
"""Collapse duplicate-key rows in ``partition_data`` to the last
occurrence of each key, preserving relative order. No-op when the
input has no duplicates."""
key_to_last_idx: Dict[_KeyTuple, int] = {}
for i, key_tuple in enumerate(input_key_tuples):
key_to_last_idx[key_tuple] = i # last write wins
if len(key_to_last_idx) == len(input_key_tuples):
return partition_data, input_key_tuples
original_count = len(input_key_tuples)
dedup_indices = sorted(key_to_last_idx.values())
logger.warning(
"Deduplicated input from %d to %d rows in partition %s "
"(kept last occurrence).",
original_count, len(dedup_indices), partition_spec,
)
return (
partition_data.take(dedup_indices),
[input_key_tuples[i] for i in dedup_indices],
)
def _validate_inputs(self, data: pa.Table, upsert_keys: List[str],
update_cols: Optional[List[str]]):
"""Validate inputs before processing."""
if not self.table.options.data_evolution_enabled():
raise ValueError(
"upsert_by_arrow_with_key requires 'data-evolution.enabled' = 'true'."
)
if not self.table.options.row_tracking_enabled():
raise ValueError(
"upsert_by_arrow_with_key requires 'row-tracking.enabled' = 'true'."
)
if not upsert_keys:
raise ValueError("upsert_keys must not be empty.")
for key in upsert_keys:
if key not in self.table.field_names:
raise ValueError(
f"upsert_key '{key}' is not in table schema fields: {self.table.field_names}"
)
if key not in data.column_names:
raise ValueError(
f"upsert_key '{key}' is not in input data columns: {data.column_names}"
)
# For partitioned tables, input data must contain partition columns
partition_keys = self.table.partition_keys
if partition_keys:
missing_in_data = [pk for pk in partition_keys if pk not in data.column_names]
if missing_in_data:
raise ValueError(
f"For partitioned tables, input data must contain all partition key "
f"columns. Missing: {missing_in_data}"
)
if update_cols is not None:
for col in update_cols:
if col not in self.table.field_names:
raise ValueError(
f"Column '{col}' in update_cols is not in table schema fields: "
f"{self.table.field_names}"
)
if data.num_rows == 0:
raise ValueError("Input data is empty.")
# NOTE: duplicate-key checking is deferred to _upsert_partition so
# that partition columns can be stripped first. The same non-partition
# key may legally appear in different partitions.
def _validate_row_inputs(
self,
values_by_name: Dict[str, Any],
upsert_keys: List[str],
update_cols: Optional[List[str]]):
if not self.table.options.data_evolution_enabled():
raise ValueError(
"upsert_by_key requires 'data-evolution.enabled' = 'true'."
)
if not self.table.options.row_tracking_enabled():
raise ValueError(
"upsert_by_key requires 'row-tracking.enabled' = 'true'."
)
if not upsert_keys:
raise ValueError("upsert_keys must not be empty.")
for key in upsert_keys:
if key not in self.table.field_names:
raise ValueError(
f"upsert_key '{key}' is not in table schema fields: {self.table.field_names}"
)
unknown_fields = [
field_name
for field_name in values_by_name
if field_name not in self.table.field_names
]
if unknown_fields:
raise ValueError(
f"upsert_by_key got row field(s) {unknown_fields} "
f"that are not in table schema fields: {self.table.field_names}"
)
require_columns(values_by_name, upsert_keys, "upsert_by_key")
require_columns(values_by_name, self.table.partition_keys, "upsert_by_key")
if update_cols is not None:
for col in update_cols:
if col not in self.table.field_names:
raise ValueError(
f"Column '{col}' in update_cols is not in table schema fields: "
f"{self.table.field_names}"
)
def _build_key_to_row_ids_map(
self,
match_keys: List[str],
partition_spec: Optional[Dict[str, Any]],
input_key_set: set,
) -> Dict[_KeyTuple, List[int]]:
"""
Scan the partition in batches and collect key → [_ROW_ID, ...] for
rows whose composite key is in *input_key_set*.
The partition spec (if any) is pushed down as an ``and`` of per-key
equality predicates so non-matching partitions are pruned by the
scanner. The match-key filter itself is applied in Python on each
batch — this keeps memory proportional to the input key set rather
than the entire partition.
Args:
match_keys: Column names used as the composite match key
(partition columns already stripped).
partition_spec: Dict of partition_key → value to restrict the scan.
Pass an empty dict (or None) for non-partitioned tables.
input_key_set: Set of composite key tuples from the input data.
"""
read_builder = self.table.new_read_builder()
if partition_spec:
predicate_builder = read_builder.new_predicate_builder()
sub_predicates = [
predicate_builder.equal(k, v)
for k, v in partition_spec.items()
]
partition_predicate = predicate_builder.and_predicates(sub_predicates)
read_builder = read_builder.with_filter(partition_predicate)
scan = read_builder.new_scan()
splits = scan.plan_for_write().splits()
if not splits:
return {}
# Read only the key columns + _ROW_ID
key_fields = [self.table.field_dict[k] for k in match_keys]
read_type = key_fields + [SpecialFields.ROW_ID]
table_read = TableRead(
table=self.table, predicate=None, read_type=read_type
)
# Stream batches and filter against input_key_set on-the-fly
key_to_row_ids: Dict[_KeyTuple, List[int]] = {}
row_id_col = SpecialFields.ROW_ID.name
for batch in table_read.to_arrow_batch_reader(splits):
batch_key_cols = [batch.column(k).to_pylist() for k in match_keys]
batch_row_ids = batch.column(row_id_col).to_pylist()
for j, row_id in enumerate(batch_row_ids):
key_tuple = tuple(col[j] for col in batch_key_cols)
if key_tuple in input_key_set:
key_to_row_ids.setdefault(key_tuple, []).append(row_id)
return key_to_row_ids
def _do_updates(
self,
data: pa.Table,
matched_indices: List[int],
input_key_tuples: List[_KeyTuple],
key_to_row_ids: Dict[_KeyTuple, List[int]],
update_cols: Optional[List[str]]
) -> List[CommitMessage]:
"""Update matched rows in-place via :class:`TableUpdateByRowId`."""
expanded_input_indices: List[int] = []
row_ids: List[int] = []
for i in matched_indices:
for row_id in key_to_row_ids[input_key_tuples[i]]:
expanded_input_indices.append(i)
row_ids.append(row_id)
update_data = data.take(expanded_input_indices).append_column(
SpecialFields.ROW_ID.name, pa.array(row_ids, type=pa.int64()),
)
cols_to_update = list(update_cols) if update_cols else list(self.table.field_names)
return TableUpdateByRowId(
self.table, self.commit_user, self.commit_identifier,
).update_columns(update_data, cols_to_update)
def _do_appends(
self,
data: pa.Table,
new_indices: List[int],
) -> List[CommitMessage]:
"""
Append rows that have no matching upsert key.
New rows are always written with *all* columns — ``update_cols``
only restricts which columns are rewritten for *matched* rows.
:class:`StreamTableWrite` is used so the produced commit messages
carry this upsert's ``commit_identifier``; in batch mode the
identifier is :data:`BATCH_COMMIT_IDENTIFIER`, so the on-disk
result is identical to using :class:`BatchTableWrite`.
"""
new_data = data.take(new_indices)
# Reorder columns to match table schema order
all_ordered_cols = [c for c in self.table.field_names if c in new_data.column_names]
new_data = new_data.select(all_ordered_cols)
table_write = StreamTableWrite(self.table, self.commit_user)
try:
table_write.with_write_type(all_ordered_cols)
table_write.write_arrow(new_data)
return table_write.prepare_commit(self.commit_identifier)
finally:
table_write.close()
def _append_rows(
self,
row_items: List[Tuple[Any, Dict[str, Any]]],
) -> List[CommitMessage]:
all_ordered_cols = self._append_row_column_names(row_items)
table_write = StreamTableWrite(self.table, self.commit_user)
try:
table_write.with_write_type(all_ordered_cols)
for row, _ in row_items:
table_write.write_row(row)
return table_write.prepare_commit(self.commit_identifier)
finally:
table_write.close()
def _append_row_column_names(
self,
row_items: List[Tuple[Any, Dict[str, Any]]],
) -> List[str]:
first_field_names = set(row_items[0][1])
for _, values_by_name in row_items[1:]:
field_names = set(values_by_name)
if field_names == first_field_names:
continue
missing_fields = [
name for name in self.table.field_names
if name in first_field_names and name not in field_names
]
extra_fields = [
name for name in self.table.field_names
if name in field_names and name not in first_field_names
]
raise ValueError(
"upsert_by_key requires appended rows in the same batch to "
"have the same field set. Compared with the first appended "
f"row, missing fields: {missing_fields}; "
f"extra fields: {extra_fields}."
)
return [
name for name in self.table.field_names if name in first_field_names
]