| # 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 |
| ] |